Packages
fnord
0.8.87
0.9.40
0.9.39
0.9.38
0.9.37
0.9.36
0.9.35
0.9.34
0.9.33
0.9.32
0.9.31
0.9.30
0.9.29
0.9.28
0.9.27
0.9.26
0.9.25
0.9.24
0.9.23
0.9.22
0.9.21
0.9.20
0.9.19
0.9.18
0.9.17
0.9.16
0.9.15
0.9.14
0.9.13
0.9.12
0.9.11
0.9.10
0.9.9
0.9.8
0.9.7
0.9.6
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.99
0.8.98
0.8.97
0.8.96
0.8.95
0.8.94
0.8.93
0.8.92
0.8.91
0.8.90
0.8.89
0.8.88
0.8.87
0.8.86
0.8.85
0.8.84
0.8.83
0.8.82
0.8.81
0.8.80
0.8.79
0.8.78
0.8.77
0.8.76
0.8.75
0.8.74
0.8.73
0.8.72
0.8.71
0.8.70
0.8.69
0.8.68
0.8.67
0.8.66
0.8.65
0.8.64
0.8.63
0.8.62
0.8.61
0.8.60
0.8.59
0.8.58
0.8.57
0.8.56
0.8.55
0.8.54
0.8.53
0.8.52
0.8.51
0.8.50
0.8.49
0.8.48
0.8.47
0.8.46
0.8.45
0.8.44
0.8.43
0.8.42
0.8.41
0.8.40
0.8.39
0.8.38
0.8.37
0.8.36
0.8.35
0.8.34
0.8.33
0.8.32
0.8.31
0.8.30
0.8.29
0.8.27
0.8.26
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.1
0.8.0
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.3
0.7.2
0.7.1
0.7.0
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.1
0.6.0
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.44
0.4.43
0.4.42
0.4.41
0.4.40
0.4.39
0.4.38
0.4.37
0.4.36
0.4.35
0.4.34
0.4.33
0.4.32
0.4.30
0.4.29
0.4.28
0.4.27
0.4.26
0.4.25
0.4.24
0.4.23
0.4.22
0.4.21
0.4.20
0.4.19
0.4.18
0.4.17
0.4.16
0.4.15
0.4.14
0.4.13
0.4.12
0.4.11
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.0
0.2.0
0.1.0
AI code archaeology
Current section
Files
Jump to
Current section
Files
lib/services/approvals/shell.ex
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)
case reject_shell_invocations(state, commands) do
{:error, reason} ->
{:denied, reason, state}
:ok ->
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
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",
"shellcheck",
"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 ls-tree",
"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_literal_prefix_approved?(state, full) 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
defp full_literal_prefix_approved?(state, full) do
settings = Settings.new()
stored =
Settings.Approvals.get_approvals(settings, :global, "shell") ++
Settings.Approvals.get_approvals(settings, :project, "shell")
session_prefixes =
state
|> Map.get(:session, [])
|> Enum.flat_map(fn
{:prefix, p} when is_binary(p) -> [p]
_ -> []
end)
(stored ++ session_prefixes)
|> Enum.any?(fn p -> is_binary(p) and String.starts_with?(full, p) end)
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
defp unapproved_prefixes(state, stages) do
stages
|> Enum.uniq()
|> Enum.reject(fn {prefix, full} -> approved?(state, {prefix, full}) end)
|> Enum.map(fn {prefix, _full} -> prefix end)
|> Enum.uniq()
end
@spec customize(state, list({String.t(), String.t()})) :: {:approved, state}
def customize(state, stages) do
unapproved_prefixes(state, stages)
|> 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
# Helper to detect shell invocations
defp shell_invocation?(%{"command" => path, "args" => args}) do
base = Path.basename(path)
shells = ["sh", "bash", "zsh", "ksh", "dash", "fish"]
cond do
# direct shell invocation: bash/sh/zsh/ksh/dash/fish -c/ -lc
base in shells and Enum.any?(args, &(&1 == "-c" or &1 == "-lc")) ->
true
# shell script invocation without -c/-lc: exec script file
base in shells and Enum.find(args, fn arg -> not String.starts_with?(arg, "-") end) != nil ->
true
# env-based shell invocation: env [VAR=val]* [flags]* bash -c '...' or script
base == "env" ->
has_shell = Enum.any?(args, &(&1 in shells))
has_flag = Enum.any?(args, &(&1 == "-c" or &1 == "-lc"))
has_non_flag_after =
case Enum.find_index(args, &(&1 in shells)) do
nil ->
false
idx ->
args
|> Enum.drop(idx + 1)
|> Enum.any?(fn arg -> not String.starts_with?(arg, "-") end)
end
has_shell and (has_flag or has_non_flag_after)
true ->
false
end
end
# Reject any shell invocations in the commands list
@spec reject_shell_invocations(state, [map]) :: :ok | {:error, String.t()}
defp reject_shell_invocations(_state, commands) do
case Enum.find(commands, &shell_invocation?/1) do
invoc when not is_nil(invoc) ->
{:error, "shell invocation not allowed: #{format_stage(invoc)}"}
nil ->
:ok
end
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()
# ----------------------------------------------------------------------------
# Public helpers for user-configured approvals (for display/spec composition)
# ----------------------------------------------------------------------------
@doc """
Returns a sorted, de-duplicated list of user-configured prefix approvals
for shell commands (kind: "shell") across both global and project scopes.
"""
@spec list_user_prefixes() :: [String.t()]
def list_user_prefixes do
settings = Settings.new()
global = Settings.Approvals.get_approvals(settings, :global, "shell")
project = Settings.Approvals.get_approvals(settings, :project, "shell")
global
|> Enum.concat(project)
|> Enum.filter(&is_binary/1)
|> Enum.uniq()
|> Enum.sort()
end
@doc """
Returns a sorted, de-duplicated list of user-configured full-command regex
approvals for shell commands (kind: "shell_full") across both global and
project scopes.
"""
@spec list_user_regexes() :: [String.t()]
def list_user_regexes do
settings = Settings.new()
global = Settings.Approvals.get_approvals(settings, :global, "shell_full")
project = Settings.Approvals.get_approvals(settings, :project, "shell_full")
global
|> Enum.concat(project)
|> Enum.filter(&is_binary/1)
|> Enum.uniq()
|> Enum.sort()
end
end