defmodule Services.Approvals.Shell do # ---------------------------------------------------------------------------- # Globals # ---------------------------------------------------------------------------- @type state :: Services.Approvals.Workflow.state() @type decision :: Services.Approvals.Workflow.decision() @type args :: {String.t(), list(map), String.t()} @approve "Approve" @persistent "Approve persistently" @deny "Deny" @deny_feedback "Deny with feedback" @session "Approve for this session" @project "Approve for the project" @global "Approve globally" @no_feedback "The user denied the request." @no_tty """ The application is not running in an interactive terminal. The user cannot respond to prompts, so they were unable to approve or deny the request. """ # ---------------------------------------------------------------------------- # Behaviour implementation # ---------------------------------------------------------------------------- @behaviour Services.Approvals.Workflow @impl Services.Approvals.Workflow @spec confirm(state, args) :: decision def confirm(state, {op, commands, purpose}) when is_list(commands) do with :ok <- validate_commands(commands) do # Build list of {prefix, full_command} pairs stages = Enum.map(commands, fn cmd -> { extract_prefix(cmd), format_stage_for_match(cmd) } end) if Enum.all?(stages, &approved?(state, &1)) do {:approved, state} else if !UI.is_tty?() do UI.error("Shell", @no_tty) {:denied, @no_tty, state} else UI.interact(fn -> render_pipeline(state, op, commands, purpose) prompt(state, stages) end) end end else {:error, reason} -> {:denied, reason, state} end end # Input validation # ---------------------------------------------------------------------------- defp validate_commands([]), do: :ok defp validate_commands([cmd | rest]) do with :ok <- validate_command(cmd), :ok <- validate_commands(rest) do :ok end end defp validate_command(%{"command" => cmd, "args" => args}) do cond do !is_binary(cmd) -> {:error, "command must be a string"} !is_list(args) -> {:error, "args must be a list"} !Enum.all?(args, &is_binary/1) -> {:error, "all args must be strings"} true -> :ok end end defp validate_command(_), do: {:error, "expected object with keys 'command' and 'args'"} # ---------------------------------------------------------------------------- # Approval checks # ---------------------------------------------------------------------------- # Pre-approved regex for read-only sed commands commonly used by LLMs # Supports: -n (quiet), -E (extended regex), basic s/// substitutions, p (print), d (delete without -i) # Blocks: -i (in-place), -f (script files), -e (expressions), s///e (execute), w/W/r file operations # no in-place editing # no script files # no -e expressions (conservative) # no s///e or s|||e execute in substitution # no w/W/r commands with filenames # no addressed w/W/r commands # no range w/W/r commands @sed_readonly_pattern "^sed" <> "(?!.*\\s-i\\b)" <> "(?!.*\\s-f\\b)" <> "(?!.*\\s-e\\b)" <> "(?!.*s[|/].*[|/].*[|/][gp0-9]*e)" <> "(?!.*\\b[wWr]\\s+\\S)" <> "(?!.*\\d+[wWr]\\b)" <> "(?!.*,[wWr]\\b)" <> ".+$" @full_cmd [ "^find(?!.*-exec)", @sed_readonly_pattern ] @ro_cmd [ "ag", "cat", "col", "cut", "diff", "echo", "fgrep", "grep", "head", "jq", "ls", "nl", "pwd", "rg", "sort", "tac", "tail", "tr", "tree", "uniq", "wc", # git subcommands "git blame", "git branch", "git describe", "git diff", "git grep", "git log", "git ls-files", "git merge-base", "git rev-list", "git rev-parse", "git show", "git status" ] @rw_cmd [ "mkdir", "touch", "cp", "mv", "rm", "sed", "awk", "patch", "truncate" ] def preapproved_cmds do if edit?() and auto?() do @ro_cmd ++ @rw_cmd else @ro_cmd end end # Returns true if either the prefix path or the full command string is # approved or a stored full-command approval matches this stage. defp approved?(state, {prefix, full}) do prefix_approved?(state, prefix) or full_cmd_preapproved?(state, full) end defp session_approvals(%{session: session}, kind) do session |> Enum.reduce([], fn {^kind, prefix}, acc -> [prefix | acc] _, acc -> acc end) end def prefix_approved?(state, prefix) do cond do prefix in preapproved_cmds() -> true prefix in session_approvals(state, :prefix) -> true Settings.Approvals.prefix_approved?(Settings.new(), "shell", prefix) -> true true -> false end end def full_cmd_preapproved?(state, full) do Settings.Approvals.approved?(Settings.new(), "shell_full", full) or @full_cmd |> Enum.concat(session_approvals(state, :full)) |> Enum.map(fn re -> Regex.compile!(re, "u") end) |> Enum.any?(&Regex.match?(&1, full)) end # ---------------------------------------------------------------------------- # Display # ---------------------------------------------------------------------------- defp render_pipeline(state, op, commands, purpose) do stages = commands |> Enum.with_index(1) |> Enum.flat_map(fn {cmd, i} -> tags = if approved?(state, {extract_prefix(cmd), format_stage_for_match(cmd)}) do [:green, :bright] else [:red, :bright] end formatted = format_stage(cmd) item = "#{i}. #{formatted}" [Owl.Data.tag(item, tags), "\n\n"] end) op_msg = case op do "|" -> "Pipeline of Commands" "&&" -> "Chained Commands" end [ Owl.Data.tag("# Approval Scope ", [:red_background, :black, :bright]), "\n\n#{op_msg}:\n\n" ] |> Enum.concat(stages) |> Enum.concat([ Owl.Data.tag("# Purpose ", [:red_background, :black, :bright]), "\n\n", purpose ]) |> UI.box( title: " Shell ", min_width: 80, padding: 1, horizontal_align: :left, border_tag: [:red, :bright] ) end defp format_stage(%{"command" => cmd, "args" => args}) do Enum.join([cmd | args], " ") end # Build the string used for shell_full matching: use basename(command) + args defp format_stage_for_match(%{"command" => cmd, "args" => args}) do base = Path.basename(cmd) Enum.join([base | args], " ") end # ---------------------------------------------------------------------------- # Prompt + persistence # ---------------------------------------------------------------------------- defp prompt(state, stages) do opts = [@approve, @persistent, @deny, @deny_feedback] Settings.get_auto_policy() |> case do {:approve, ms} -> UI.choose("Approve this request?", opts, ms, @approve) {:deny, ms} -> UI.choose("Approve this request?", opts, ms, @deny) _ -> UI.choose("Approve this request?", opts) end |> case do @approve -> {:approved, state} @deny -> {:denied, build_auto_deny_message(), state} @deny_feedback -> {:denied, get_feedback(), state} @persistent -> customize(state, stages) end end @spec customize(state, [String.t()] | list({String.t(), String.t()})) :: {:approved, state} def customize(state, stages) do stages |> Enum.uniq() # Filter out already approved stages |> Enum.reject(fn {prefix, full} -> approved?(state, {prefix, full}) end) # Extract just the prefixes |> Enum.map(fn {prefix, _full} -> prefix end) # Iterate over each unapproved prefix and ask for scope or regex |> Enum.reduce({:approved, state}, fn prefix, {:approved, acc_state} -> choose_scope(acc_state, prefix) end) end defp choose_scope(state, prefix) do {kind, pattern} = custom_pattern(state, prefix) scope = UI.choose( """ Choose approval scope for: #{pattern} """, [@session, @project, @global] ) case kind do :prefix -> approve_scope(scope, state, pattern) :full -> approve_regex_scope(scope, state, pattern, prefix) end end defp custom_pattern(state, prefix) do UI.prompt( """ You may optionally select your own approval prefix, making it broader or more specific. Wrap your input in slashes (/) to treat it as a regular expression. Regular expressions are matched against the entire command string, including arguments. There is NO implicit anchoring, so include ^ and $ as needed. Options: 1. Customize the prefix (e.g. `docker image` to `docker image ls` or `docker` to change the specificity) 2. Enter a regular expression (e.g. `/^find(?!.*-exec).+$/` to allow find without -exec) 3. *Leave blank* to approve the default prefix as shown. CURRENT PREFIX: #{prefix} """, optional: true ) |> case do {:error, :no_tty} -> {:prefix, prefix} nil -> {:prefix, prefix} "" -> {:prefix, prefix} str -> str = String.trim(str) if String.starts_with?(str, "/") and String.ends_with?(str, "/") do re = String.slice(str, 1..-2//1) if re == "" do UI.error("Empty regex is not allowed") custom_pattern(state, prefix) else case Regex.compile(re, "u") do {:ok, _} -> {:full, re} {:error, {msg, _pos}} -> UI.error("Invalid regex: #{to_string(msg)}") custom_pattern(state, prefix) end end else {:prefix, str} end end end defp approve_scope(@session, %{session: session} = state, prefix) do prefixes = session |> Enum.concat([{:prefix, prefix}]) |> Enum.uniq() {:approved, %{state | session: prefixes}} end defp approve_scope(@project, state, prefix) do Settings.new() |> Settings.Approvals.approve(:project, "shell", prefix) {:approved, state} end defp approve_scope(@global, state, prefix) do Settings.new() |> Settings.Approvals.approve(:global, "shell", prefix) {:approved, state} end defp approve_regex_scope(@session, %{session: session} = state, inner, _prefix) do prefixes = session |> Enum.concat([{:full, inner}]) |> Enum.uniq() {:approved, %{state | session: prefixes}} end defp approve_regex_scope(@project, state, inner, _prefix) do Settings.new() |> Settings.Approvals.approve(:project, "shell_full", inner) {:approved, state} end defp approve_regex_scope(@global, state, inner, _prefix) do Settings.new() |> Settings.Approvals.approve(:global, "shell_full", inner) {:approved, state} end # ---------------------------------------------------------------------------- # Utilities # ---------------------------------------------------------------------------- @doc """ Delegate to the pure prefix extraction logic. """ def extract_prefix(%{"command" => cmd, "args" => args}) do base_cmd = Path.basename(cmd) Services.Approvals.Shell.Prefix.extract(base_cmd, args) end # Prompt user for feedback when denying with feedback defp get_feedback do "Feedback:" |> UI.prompt() |> then(&"The user denied the request with the following feedback: #{&1}") end defp build_auto_deny_message() do case Settings.get_auto_policy() do {:deny, ms} when is_integer(ms) and ms > 0 -> seconds = div(ms, 1000) """ The request was automatically denied after #{seconds} seconds due to an active auto-deny policy. The user may not be monitoring their terminal. Only use pre-approved shell commands that will not require user confirmation. """ |> String.trim() _ -> @no_feedback end end defp edit?, do: Settings.get_edit_mode() defp auto?, do: edit?() && Settings.get_auto_approve() end