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 nginx | ansible.builtin.apt |
command: useradd deploy | ansible.builtin.user |
shell: mkdir -p /opt/app | ansible.builtin.file |
shell: cp file.txt /tmp/ | ansible.builtin.copy |
shell: echo "line" >> file | ansible.builtin.lineinfile |
command: systemctl restart nginx | ansible.builtin.service |
shell: curl http://api.example.com | ansible.builtin.uri |
command: git clone ... | ansible.builtin.git |
shell: pip install flask | ansible.builtin.pip |
command: crontab -e | ansible.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:
| Module | Process Chain | Overhead |
|---|---|---|
command | SSH → command directly | Minimal |
shell | SSH → /bin/sh -c → command | Extra 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
- Default to
command— only switch toshellwhen you need shell features - Prefer dedicated modules over both
commandandshell - Always add
changed_whento bothcommandandshelltasks - Use
| quotefilter when passing variables toshell - Run
ansible-lintto catch unnecessaryshellusage - Document why when you use
shell— a comment helps future maintainers
Related Articles
- Ansible shell Module Guide
- Ansible changed_when failed_when Guide
- Ansible Lint Guide
- Ansible Error 301 no-changed-when
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.