Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions .github/workflows/lshell-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ jobs:
python-version: ["3.10", "3.14"]

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Set up Python path
Expand All @@ -34,6 +34,9 @@ jobs:
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Install the lshell package
run: pip install .
- name: Check config coverage manifest freshness
run: |
python3 scripts/update_config_coverage_manifest.py --check
- name: Test with pytest
run: |
pytest
Expand All @@ -47,9 +50,9 @@ jobs:
python-version: ["3.10", "3.14"]

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Set up Python path
Expand All @@ -72,7 +75,7 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install just
uses: taiki-e/install-action@just
- name: Fuzz security parser/policy
Expand All @@ -85,7 +88,7 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Run SSH end-to-end tests
run: |
docker compose -f docker-compose.e2e.yml up --build --abort-on-container-exit --exit-code-from ansible-runner ansible-runner
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/pypi-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ jobs:
id-token: write

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 2
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.x"
- name: Decide whether to publish
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Contact: [ghantoos@ghantoos.org](mailto:ghantoos@ghantoos.org)
- Refactor: Removed legacy `lshell.parser` compatibility wrapper; runtime and diagnostics now rely on canonical `lshell.engine.*` parsing paths only.
- Refactor: Reorganized configuration code into `lshell/config/` with focused modules (`runtime.py`, `diagnostics.py`, `resolve.py`, `schema.py`) and updated imports accordingly.
- Config: Unified runtime (`CheckConfig`) and diagnostics (`policy-show`) merge logic into a shared resolver to keep section precedence, include overlays, +/- list operations, `all` expansion, and glob-path handling aligned.
- Runtime/Security: Added explicit `runtime_executor` mode selection (`shellless` default, `bash_compat` opt-in), trusted absolute bash resolution (no PATH lookup), and retained env hardening (`BASH_ENV`/`ENV`/`BASH_FUNC_*` scrubbing).
- CLI: Restored split diagnostics naming: `policy-show` as the external subcommand and `lshow` as the in-shell builtin.
- CLI: Removed legacy/extra diagnostics commands `lpath`, `lsudo`, `policy-path`, and `policy-sudo`.
- UX: Extended `policy-show` output to include path allow/deny details and sudo policy details directly.
Expand Down
6 changes: 0 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,6 @@ Inside an interactive session:
`lshow` includes effective command policy, allowed/denied paths, and sudo
policy in one output.

Hide these built-ins if needed:

```ini
policy_commands : 0
```

## Hardened profile generator

`harden-init` ships secure-by-default templates to bootstrap restricted accounts quickly:
Expand Down
35 changes: 35 additions & 0 deletions ansible/playbooks/ssh_e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
test_user_winscp: ltestwinscp
test_user_pipe_on: ltestpipeon
test_user_alias: ltestalias
test_user_shellless: ltestshellless
test_user_bash_compat: ltestbashcompat
test_user_scp_over: ltestscpover
test_user_scp_force: ltestscpforce
test_home: /home/ltest
Expand All @@ -24,6 +26,8 @@
test_user_winscp_password: ltestwinscppass
test_user_pipe_on_password: ltestpipeonpass
test_user_alias_password: ltestaliaspass
test_user_shellless_password: ltestshelllesspass
test_user_bash_compat_password: ltestbashcompatpass
test_user_scp_over_password: ltestscpoverpass
test_user_scp_force_password: ltestscpforcepass
tasks:
Expand Down Expand Up @@ -158,6 +162,32 @@
executable: /bin/bash
changed_when: false

- name: Create SSH test user with explicit shellless runtime executor profile
ansible.builtin.user:
name: "{{ test_user_shellless }}"
shell: "{{ lshell_bin }}"
create_home: true

- name: Set password for shellless-runtime SSH test user
ansible.builtin.shell: |
echo "{{ test_user_shellless }}:{{ test_user_shellless_password }}" | chpasswd
args:
executable: /bin/bash
changed_when: false

- name: Create SSH test user with bash_compat runtime executor profile
ansible.builtin.user:
name: "{{ test_user_bash_compat }}"
shell: "{{ lshell_bin }}"
create_home: true

- name: Set password for bash_compat SSH test user
ansible.builtin.shell: |
echo "{{ test_user_bash_compat }}:{{ test_user_bash_compat_password }}" | chpasswd
args:
executable: /bin/bash
changed_when: false

- name: Create SSH test user with scp fallback through overssh
ansible.builtin.user:
name: "{{ test_user_scp_over }}"
Expand Down Expand Up @@ -221,6 +251,7 @@
- { user: "{{ test_user_scp_off }}" }
- { user: "{{ test_user_winscp }}" }
- { user: "{{ test_user_alias }}" }
- { user: "{{ test_user_bash_compat }}" }
- { user: "{{ test_user_scp_over }}" }
- { user: "{{ test_user_scp_force }}" }

Expand Down Expand Up @@ -265,6 +296,8 @@
ssh_user_winscp: ltestwinscp
ssh_user_pipe_on: ltestpipeon
ssh_user_alias: ltestalias
ssh_user_shellless: ltestshellless
ssh_user_bash_compat: ltestbashcompat
ssh_user_scp_over: ltestscpover
ssh_user_scp_force: ltestscpforce
ssh_host: lshell-ssh-target
Expand All @@ -276,6 +309,8 @@
ssh_password_winscp: ltestwinscppass
ssh_password_pipe_on: ltestpipeonpass
ssh_password_alias: ltestaliaspass
ssh_password_shellless: ltestshelllesspass
ssh_password_bash_compat: ltestbashcompatpass
ssh_password_scp_over: ltestscpoverpass
ssh_password_scp_force: ltestscpforcepass
ssh_common_opts: >-
Expand Down
9 changes: 9 additions & 0 deletions ansible/templates/lshell.conf.j2
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ scp_upload : 0
scp_download : 1
sftp : 1
strict : 1
runtime_executor : shellless
warning_counter : 2
allowed_file_extensions : ['.txt']

Expand Down Expand Up @@ -39,6 +40,14 @@ forbidden : [';', '&', '`', '>', '<', '$(', '${']
[ltestalias]
aliases : {'ll': 'ls'}

[ltestshellless]
runtime_executor : shellless
overssh : + ['echo']

[ltestbashcompat]
runtime_executor : bash_compat
overssh : + ['echo']

[ltestscpover]
scp : 0

Expand Down
14 changes: 14 additions & 0 deletions ansible/vars/ssh_scenarios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,20 @@ ssh_scenarios:
expected_rc: 0
expect_text: "hello from txt"

- name: shellless runtime_executor keeps tilde literal
command: "echo ~"
user: "ltestshellless"
password: "ltestshelllesspass"
expected_rc: 0
expect_text: "~"

- name: bash_compat runtime_executor expands tilde
command: "echo ~"
user: "ltestbashcompat"
password: "ltestbashcompatpass"
expected_rc: 0
expect_text: "/home/ltestbashcompat"

- name: blocks chained extension violation
command: "cat readme.txt && cat secret.bin"
expected_rc: 1
Expand Down
41 changes: 41 additions & 0 deletions docs/shellless-execution-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Shellless Execution Migration

This change removes implicit runtime `shell -c` execution and introduces an explicit
runtime executor mode switch:

- `runtime_executor=shellless` (default, hardened/fail-closed)
- `runtime_executor=bash_compat` (explicit compatibility opt-in)

## Compatibility Matrix

| Feature | Policy/Parser Handling | `shellless` Runtime | `bash_compat` Runtime | Notes |
| --- | --- | --- | --- | --- |
| Quoting (`'...'`, `"..."`, escapes) | Parsed by `shlex` and engine splitters | Supported | Supported | Quoting is parsed by lshell in shellless; delegated to bash in compat mode. |
| Variable expansion (`$VAR`, `${...}` subset) | Authorized through expansion inspector checks | Supported for lshell-supported forms via `expand_vars_quoted` | Supported by bash | Unsupported/unsafe forms remain fail-closed in policy path. |
| Globbing (`*`, `?`, `[]`, brace patterns) | Path ACL checks expand wildcard/brace forms for authorization | No implicit shell glob expansion at execution time | Bash globbing semantics | Path ACL checks still run before execution. |
| Pipelines (`|`) | Parsed by canonical engine | Supported via explicit `Popen` pipe wiring | Supported via bash parser/executor | Runtime containment still applies per execution mode constraints. |
| `&&`, `||`, `;`, `&` | Canonical engine sequencing/branching logic | Supported | Supported | |
| Command substitution (`$(...)`, `` `...` ``) | Nested commands still recursively authorized (allowlist/path) | Unsupported at runtime (fail-closed) | Allowed (historic behavior) | |
| Process substitution (`<(...)`, `>(...)`) | Nested commands still recursively authorized (allowlist/path) | Unsupported at runtime (fail-closed) | Allowed (historic behavior) | |
| Redirections (`>`, `<`, `>>`, `2>&1`, here-doc/here-string forms) | Existing checks continue to classify malformed forms | Unsupported at runtime (fail-closed) | Supported by bash syntax, still subject to policy checks | Forbidden-character config can still block metacharacters. |

### Runtime Executor Matrix

| `runtime_executor` | Effective behavior |
| --- | --- |
| `shellless` | Hardened mode: command/process substitution denied at runtime. |
| `bash_compat` | Compatibility mode: command/process substitution allowed (historic behavior). |

## Security Outcome

- Default mode (`shellless`) keeps fail-closed behavior with no external shell parser.
- `bash_compat` uses only trusted absolute bash candidates (`/bin/bash`, `/usr/bin/bash`, etc.); no PATH-based interpreter lookup is used.
- Nested allowlist/path checks for commands inside substitutions remain enforced before execution.
- Environment hardening remains in place for both modes: `BASH_ENV`, `ENV`, and `BASH_FUNC_*` are stripped from child envs.
- `sudo_noexec` probe remains shellless and uses a trusted absolute `true` binary.

## Known Compatibility Tradeoffs

1. `bash_compat` is a compatibility mode and broadens runtime shell semantics versus `shellless`.
2. In `shellless`, shell-only forms (substitution/redirection) are denied explicitly.
3. In `bash_compat`, runtime containment limits such as pipeline-stage counting apply to the outer bash process, not each shell-internal stage.
Loading