Skip to content

Feature: Flexible command restrictions#6

Merged
dbernheisel merged 7 commits intotv-labs:mainfrom
lostbean:egomes/command-restrictions
Mar 28, 2026
Merged

Feature: Flexible command restrictions#6
dbernheisel merged 7 commits intotv-labs:mainfrom
lostbean:egomes/command-restrictions

Conversation

@lostbean
Copy link
Copy Markdown
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 commands are allowed.

Design

Bash.CommandPolicy struct

An extensible struct stored on Bash.Session state, immutable once set:

%CommandPolicy{
  commands: command_rule(),  # gates command execution
  paths: nil,               # reserved for future filesystem restrictions
  files: nil                # reserved for future file access restrictions
}

Category-aware policy engine

Every command falls into one of four categories, and rules can gate any of them:

Category Atom in rules What it covers
:builtin :builtins echo, cd, export, etc.
:external :externals OS commands on $PATH
:function :functions User-defined name() { ... }
:interop :interop Elixir interop via defbash

Common recipes

Goal Policy
Allow everything (default) commands: :unrestricted
Block all externals commands: :no_external
Builtins only commands: [{:allow, [:builtins]}]
Builtins + functions commands: [{:allow, [:builtins, :functions]}]
Builtins + specific externals commands: [{:allow, [:builtins, "cat", "grep"]}]
Everything except externals commands: [{:disallow, [:externals]}, {:allow, :all}]
Block specific builtins commands: [{:disallow, ["eval", "source"]}, {:allow, :all}]
Block everything commands: [{:disallow, :all}]

Rule evaluation

Rules are evaluated in order, first match wins, fail-closed (no match = deny):

  • {:allow, items} / {:disallow, items} — items can be strings, regexes, or category atoms
  • {:allow, :all} / {:disallow, :all} — catch-all
  • fun/2 — receives (name, category), return true to allow
  • fun/1 — legacy, only evaluated for :external commands

Command dispatch restructured

resolve_and_execute in AST.Command now follows resolve-then-check-then-dispatch:

  1. Resolve — determine category (:function, :interop, :builtin, or :external)
  2. CheckCommandPolicy.check_command(policy, name, category)
  3. Dispatch — execute via the appropriate handler

Policy enforcement points

Enforcement Point Category
AST.Command.resolve_and_execute All categories (resolve → check → dispatch)
Pipeline.external_command? :external (streaming optimization)
exec builtin :external
command builtin :external (run and lookup)
coproc builtin :external
Session (background jobs) :external

Backwards compatible

  • check_command/2 defaults to :external category
  • :no_external only blocks externals (builtins/functions/interop pass through)
  • fun/1 policies only fire for externals
  • options: %{restricted: true} normalized to command_policy: :no_external

Immutable once set

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

Usage

# Block all external commands
{:ok, session} = Bash.Session.new(command_policy: [commands: :no_external])

# Allow builtins + specific externals
{:ok, session} = Bash.Session.new(
  command_policy: [commands: [{:allow, [:builtins, "cat", "grep"]}]]
)

# Allow builtins and functions, block externals and interop
{:ok, session} = Bash.Session.new(
  command_policy: [commands: [{:allow, [:builtins, :functions]}]]
)

# Block dangerous builtins, allow everything else
{:ok, session} = Bash.Session.new(
  command_policy: [commands: [{:disallow, ["eval", "source"]}, {:allow, :all}]]
)

# Category-aware function
{:ok, session} = Bash.Session.new(
  command_policy: [commands: fn _name, cat -> cat in [:builtin, :function] end]
)

Test plan

  • All 2195 tests pass (0 failures)
  • test/bash/command_restrictions_test.exs — 142 tests covering:
    • :no_external blocks external commands (simple, absolute path, pipeline, subshell, command substitution, background jobs)
    • Allowlist/denylist with strings, regexes, and functions
    • Category-aware policies: builtins-only, externals-only, functions-only, interop-only
    • All multi-category combinations (builtins+externals, builtins+functions, builtins+interop, builtins+functions+interop)
    • Disallow specific categories while allowing the rest
    • Disallow specific commands by name across categories
    • {:disallow, :all} blocks everything
    • fun/2 receives category, fun/1 backwards compat (external-only)
    • Policy inherits to subshells and command substitutions
    • Policy is immutable via set and shopt
    • command -v respects policy
    • exec, coproc, background jobs, pipelines respect all policy types
    • CommandPolicy struct: normalization, check_command/3, command_allowed?/3
  • mix format --check-formatted clean
  • mix compile --warnings-as-errors clean

Copy link
Copy Markdown
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.

@lostbean lostbean force-pushed the egomes/command-restrictions branch from eaa809f to c62d70a Compare March 27, 2026 02:22
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 c62d70a to 3892fbe Compare March 27, 2026 10:13
Replace the union type (`@type t :: :unrestricted | :disallow_external |
{:allow, MapSet.t()}`) with a `%CommandPolicy{}` struct supporting three
dimensions: commands, paths, and files.

The commands field now supports:
- `:unrestricted` / `:no_external` atoms
- Rule lists with `{:allow, items}`, `{:disallow, items}`, and `fn/1`
- Items can be strings (exact + basename match), Regex, or `:all` catch-all
- Single function `(String.t() -> boolean())` for dynamic evaluation

Rules evaluate in order — first match wins, deny by default.

Structural changes:
- Promoted from `state.options.command_policy` to top-level `state.command_policy`
- Immutability now automatic (set builtin only touches options map)
- `paths` and `files` fields reserved for future filesystem policy
- Removed all legacy/backwards-compat code (restricted: true, :disallow_external)
Cover all enforcement points across all policy types:
- Coproc restriction (was completely untested)
- Exec with allowlist, denylist, and function policies
- Background jobs with allowlist, denylist, and function policies
- Pipeline with denylist, function, and regex policies
- command -v with denylist and function policies
- Session inheritance (struct form, pre-built struct)

Add struct edge case tests:
- Empty rule list, empty allow/disallow items
- {:allow, :all} as standalone policy
- Multiple disallow rules in sequence
- Regex full-path matching
- from_state with non-struct and nil values
- new/1 from map, function that always rejects

69 → 98 test cases.
@lostbean
Copy link
Copy Markdown
Contributor Author

Hey @dbernheisel, that's a great insight on moving the data structure to a struct and planning for support across different dimensions (commands, paths, files).

I've reworked CommandPolicy into an extensible struct:

%CommandPolicy{
  commands: :unrestricted | :no_external | [rules] | fn/1,
  paths: nil,   # reserved for future filesystem policy
  files: nil    # reserved for future filesystem policy
}

Rules support strings, regex, and functions — evaluated in order, first match wins:

# Denylist with catch-all allow
Bash.Session.new(command_policy: [commands: [{:disallow, ["rm", "dd"]}, {:allow, :all}]])

# Regex-based
Bash.Session.new(command_policy: [commands: [{:allow, [~r/^git-/]}]])

# Function-based
Bash.Session.new(command_policy: [commands: fn cmd -> String.starts_with?(cmd, "safe-") end])

Also promoted command_policy from state.options to a top-level session field (like filesystem), which makes immutability automatic since set only touches the options map.

The paths and files fields are declared but not enforced yet — that'll come naturally once the filesystem branch lands, with policy checks at the Bash.Filesystem dispatch layer.

- Add command_policy to Session @type t typespec
- Document all Session.new/1 options in @doc
- Add CommandPolicy to hex docs groups_for_modules
- Add command_policy to ARCHITECTURE.md state fields table
- Add Command Policies section to README with usage examples
…erop

Policy rules can now gate all command categories, not just externals.
Category atoms (:builtins, :externals, :functions, :interop) work as
rule items alongside strings and regexes. The command dispatch in
Command AST now resolves category first, then checks policy, then
executes. Backwards compatible: check_command/2 defaults to :external,
:no_external still only blocks externals, and fun/1 only fires for
externals.
Add a Common Recipes table to the moduledoc showing how to configure
every practical category combination. Expand integration tests from 8
to 30 with systematic describe blocks covering each single-category
policy, multi-category combinations, disallow-by-category, disallow-by-
name, fun/2, and block-everything.
@lostbean lostbean requested a review from dbernheisel March 27, 2026 18:10
@dbernheisel dbernheisel merged commit aebc532 into tv-labs:main Mar 28, 2026
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