Introduction

Ansible Lint Rule 305 (command-instead-of-shell) flags tasks that use the shell module when the command module would suffice. The shell module invokes a full shell (/bin/sh) to execute commands, which introduces unnecessary overhead and potential security risks when shell features aren't needed. This article explains the difference, when each module is appropriate, and how to fix the error.

Understanding the Difference

ansible.builtin.command

  • Executes the command directly (no shell involved)
  • No access to: pipes (|), redirects (>, <, >>), environment variable expansion ($HOME), wildcards (*), command chaining (&&, ||, ;)
  • Faster: no shell process overhead
  • Safer: no shell injection risk

ansible.builtin.shell

  • Executes via /bin/sh -c "your command"
  • Full shell features: pipes, redirects, wildcards, env vars, subshells
  • Slower: spawns an extra shell process
  • Higher risk: vulnerable to shell injection if using untrusted input

The Error

Problematic Code

---
- name: Problematic example
  hosts: all
  tasks:
    - name: Echo a message
      ansible.builtin.shell: echo hello
      changed_when: false

Lint Output

$ ansible-lint playbook.yml
WARNING  Listing 1 violation(s) that are fatal
command-instead-of-shell: Use shell only when shell functionality is required.
playbook.yml:5 Task/Handler: Echo a message

                   Rule Violation Summary
 count tag                      profile rule associated tags
     1 command-instead-of-shell basic   command-shell, idiom

Failed: 1 failure(s), 0 warning(s) on 1 files.

Fixed Code

---
- name: Fixed example
  hosts: all
  tasks:
    - name: Echo a message
      ansible.builtin.command: echo hello
      changed_when: false

When to Use command (No Shell Needed)

# Simple commands
- ansible.builtin.command: whoami
- ansible.builtin.command: date
- ansible.builtin.command: uname -r
- ansible.builtin.command: cat /etc/hostname

# Running executables
- ansible.builtin.command: /opt/app/bin/start
- ansible.builtin.command: python3 manage.py migrate

# Commands with arguments
- ansible.builtin.command: systemctl status nginx
- ansible.builtin.command: openssl x509 -in cert.pem -noout -dates

# Free-form or argv style
- ansible.builtin.command:
    cmd: ls -la /var/log
    chdir: /tmp

When shell IS Required

These shell features require the shell module:

Pipes

# ✅ Requires shell — uses pipe
- ansible.builtin.shell: ps aux | grep nginx | wc -l
  changed_when: false

Redirects

# ✅ Requires shell — output redirect
- ansible.builtin.shell: echo "export PATH=/opt/bin:$PATH" >> /etc/profile

Environment Variable Expansion

# ✅ Requires shell — $HOME expansion
- ansible.builtin.shell: echo $HOME
  changed_when: false

# But consider using the command module with environment instead:
# ✅ Better approach — no shell needed
- ansible.builtin.command: echo "{{ ansible_env.HOME }}"
  changed_when: false

Wildcards/Globbing

# ✅ Requires shell — wildcard expansion
- ansible.builtin.shell: rm -f /tmp/*.log

Command Chaining

# ✅ Requires shell — && chaining
- ansible.builtin.shell: cd /opt/app && ./configure && make && make install

Subshells

# ✅ Requires shell — command substitution
- ansible.builtin.shell: echo "Today is $(date)"
  changed_when: false

Here Documents

# ✅ Requires shell — heredoc
- ansible.builtin.shell: |
    cat <<EOF > /etc/motd
    Welcome to {{ inventory_hostname }}
    Managed by Ansible
    EOF

Decision Flowchart

Does your command use:
  - Pipes (|) ?
  - Redirects (>, <, >>) ?
  - Wildcards (*, ?) ?
  - $VARIABLES ?
  - && or || or ; ?
  - $() or backticks ?
  
  YES → Use ansible.builtin.shell
  NO  → Use ansible.builtin.command
  
  Can you replace it with a dedicated module?
  YES → Use the module instead (best option)

Even Better: Use a Dedicated Module

Often, neither command nor shell is the best choice. Ansible has purpose-built modules:

Instead of shell/command...Use this module
shell: apt install nginxansible.builtin.apt
command: useradd deployansible.builtin.user
shell: mkdir -p /opt/appansible.builtin.file
shell: cp file.txt /tmp/ansible.builtin.copy
shell: echo "line" >> fileansible.builtin.lineinfile
command: systemctl restart nginxansible.builtin.service
shell: curl http://api.example.comansible.builtin.uri
command: git clone ...ansible.builtin.git
shell: pip install flaskansible.builtin.pip
command: crontab -eansible.builtin.cron

Security Implications

Shell Injection Risk

# ❌ DANGEROUS — user_input could contain malicious shell commands
- ansible.builtin.shell: "echo {{ user_input }}"

# If user_input is: hello; rm -rf /
# Shell executes: echo hello; rm -rf /
# ✅ Safe — command module doesn't interpret shell metacharacters
- ansible.builtin.command: "echo {{ user_input }}"
# Treats the entire string as arguments to echo

# ✅ Even safer — use quote filter
- ansible.builtin.command: "echo {{ user_input | quote }}"

Mitigating Shell Risks

When you must use shell, protect against injection:

# Use the quote filter for all variables
- ansible.builtin.shell: "grep {{ pattern | quote }} {{ filename | quote }}"

# Or use the args form
- ansible.builtin.shell: "grep '{{ pattern }}' '{{ filename }}'"

Performance Comparison

The command module is measurably faster because it avoids spawning a shell process:

ModuleProcess ChainOverhead
commandSSH → command directlyMinimal
shellSSH → /bin/sh -c → commandExtra shell process

For a single task, the difference is negligible (~5-10ms). But in playbooks running hundreds of tasks across many hosts, it adds up.

Common Patterns

Check-Then-Act (command)

- name: Check if app is installed
  ansible.builtin.command: which myapp
  register: app_check
  changed_when: false
  failed_when: false

- name: Install app
  ansible.builtin.command: /opt/installer/install.sh
  when: app_check.rc != 0

Process Filtering (shell required)

- name: Count nginx workers
  ansible.builtin.shell: ps aux | grep '[n]ginx' | wc -l
  register: worker_count
  changed_when: false

Log Analysis (shell required)

- name: Find recent errors
  ansible.builtin.shell: grep "ERROR" /var/log/app.log | tail -20
  register: recent_errors
  changed_when: false
  failed_when: false

Best Practices

  1. Default to command — only switch to shell when you need shell features
  2. Prefer dedicated modules over both command and shell
  3. Always add changed_when to both command and shell tasks
  4. Use | quote filter when passing variables to shell
  5. Run ansible-lint to catch unnecessary shell usage
  6. Document why when you use shell — a comment helps future maintainers

Conclusion

Ansible Lint Rule 305 (command-instead-of-shell) promotes using command over shell when shell features aren't needed. The command module is faster, safer (no shell injection risk), and more predictable. Use shell only when you need pipes, redirects, wildcards, or environment variable expansion. Better yet, use a dedicated Ansible module when one exists for your task.