Skip to content

Feature: Flexible command restrictions#6

Open
lostbean wants to merge 7 commits intotv-labs:mainfrom
lostbean:egomes/command-restrictions
Open

Feature: Flexible command restrictions#6
lostbean wants to merge 7 commits intotv-labs:mainfrom
lostbean:egomes/command-restrictions

Conversation

@lostbean
Copy link
Contributor

@lostbean lostbean commented Mar 26, 2026

Note: This is PR 2 of 3, splitting #4 (sandbox) as requested by @dbernheisel. The stack is:

  1. Refactor: Consolidate ExCmd into CommandPort #5 — Consolidate ExCmd into CommandPort
  2. This PR — Flexible command restrictions (depends on Refactor: Consolidate ExCmd into CommandPort #5)
  3. Pluggable virtual filesystem (depends on this PR)

Motivation

PR #5 consolidated all ExCmd usage into CommandPort and added restriction gates there. However, per @dbernheisel's review feedback, restriction decisions should be made at a higher level — not in the process-spawning layer. The boolean restricted: true/false is also too rigid; users need fine-grained control over which external commands are allowed.

Changes

New: Bash.CommandPolicy module

A pure-function module defining three policy types:

@type t :: :unrestricted | :disallow_external | {:allow, MapSet.t(String.t())}
  • :unrestricted — default, no restrictions
  • :disallow_external — block all external command execution
  • {:allow, commands} — whitelist specific commands (accepts list or MapSet, matches both name and Path.basename)

CommandPort is now a dumb pipe

All restriction logic removed from CommandPort. The restricted?/1 helper, is_restricted guard, and restricted parameter on start_link, stream, and system_cmd are gone. CommandPort is purely a process-spawning interface.

Policy enforcement at the AST level

Policy checks happen where command dispatch decisions are made:

Enforcement Point Mechanism
AST.Command.resolve_and_execute check_command_policy/2 between builtin fallthrough and external dispatch
Pipeline.external_command? CommandPolicy.allowed?/2 gates streaming optimization
exec builtin Checks policy before CommandPort.execute
command builtin Checks policy before external execution and in command -v lookups
coproc builtin Checks policy before spawning external coprocess
Session (background jobs) Checks policy in both do_start_background_job and start_background_job_sync

Backwards compatible

options: %{restricted: true} is normalized to command_policy: :disallow_external at session init.

Immutable once set

  • set builtin preserves command_policy across option changes
  • shopt restricted_shell is read-only and reflects command_policy != :unrestricted

Usage

# Block all external commands
{:ok, session} = Bash.Session.new(options: %{command_policy: :disallow_external})

# Allow only specific commands (list or MapSet)
{:ok, session} = Bash.Session.new(
  options: %{command_policy: {:allow, ["cat", "grep", "sort"]}}
)

# Backwards compatible
{:ok, session} = Bash.Session.new(options: %{restricted: true})

Test plan

  • All 2107 tests pass (0 failures)
  • test/bash/command_restrictions_test.exs — 55 tests covering:
    • :disallow_external blocks external commands (simple, absolute path, pipeline, subshell, command substitution, background jobs)
    • {:allow, list} permits whitelisted, blocks others
    • Builtins, functions, and Elixir interop work under all policies
    • Policy inherits to subshells and command substitutions
    • Policy is immutable via set and shopt
    • command -v respects policy
    • Backwards compat with restricted: true
    • List-to-MapSet normalization
  • test/bash/command_port_test.exs — updated (restriction tests removed, arities fixed)
  • mix format --check-formatted clean
  • mix compile — no warnings

Forward stderr from temporary OutputCollectors in pipeline execution
and command substitution to the session's stderr sink instead of
discarding it. Resolve relative file paths in while loop input
redirects against the session's working directory. Handle :exec
return value in SessionCase test helper.
Expand CommandPort to be the single interface for all user-facing OS
process execution. Add thin delegates for ExCmd.Process lifecycle
operations (os_pid, read, write, close_stdin, close_stdout, await_exit)
and gateway functions with restricted-mode enforcement (start_link,
stream, system_cmd). Internal helpers now call public delegates instead
of ExCmd.Process directly.
Replace direct ExCmd.Process and System.cmd calls in JobProcess,
Coproc, Pipeline, and Command builtin with CommandPort delegates.
CommandPort is now the single gateway for all user-facing OS process
execution. Add restricted-mode guard to external_command? in Pipeline
to prevent streaming bypass.
Copy link
Collaborator

@dbernheisel dbernheisel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of this looks to include changes from the other branch -- ignoring that, I think this is a good direction, though let's expand the CommandPolicy idea a bit into a struct that can be built to hold more decisions.

I'm thinking something like:

%CommandPolicy{
  commands: :no_external | [{:allow | :disallow, [...]}, fn x -> end] | fn x -> ... end,
  paths: [~r/.../, "..", fn x -> ... end],
  files: [~r/.../, "..", fn x -> ... end],
}

this would be evaluated at the time of the access request and respond appropriately.

nil <- try_elixir_interop(command_name, args, stdin, session_state),
nil <- try_builtin(command_name, args, stdin, session_state) do
nil <- try_builtin(command_name, args, stdin, session_state),
:ok <- check_command_policy(command_name, session_state) do
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this might need to be evaluated before builtin? What do you think

Comment on lines +208 to +212
if CommandPolicy.allowed?(CommandPolicy.from_state(state), name) do
{:file, path}
else
:not_found
end
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not quite related, but I wonder if CommandPolicy needs to be a struct so it can hold more than just an atom -- eg, they might allow/disallow commands, but also want to allow/disallow paths

Per review feedback, restricted mode is a session concern — not a
process-spawning concern. Moves the helper and updates all callers.
Move command restriction enforcement from CommandPort to AST level.
CommandPort is now a plain process-spawning pipe with no policy logic.

Three policy types:
- :unrestricted (default, no restrictions)
- :disallow_external (blocks all external commands)
- {:allow, MapSet} (whitelist specific commands)

Policy is checked in AST.Command.resolve_and_execute, Pipeline.external_command?,
and each builtin that dispatches externally (exec, command, coproc). Background
jobs also check policy before spawning. Policy is immutable once set -- cannot be
changed via set or shopt at runtime.

Backwards compatible: `restricted: true` normalizes to `:disallow_external`.
Normalize {:allow, list} to {:allow, MapSet} at session init so users
can write {:allow, ["cat", "grep"]} instead of wrapping in MapSet.new.
@lostbean lostbean force-pushed the egomes/command-restrictions branch from eaa809f to c62d70a Compare March 27, 2026 02:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants