Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
b292cda
Parser: initial commit
ghantoos Dec 4, 2024
cf69ada
Massive change to adapt lshell to new parser
ghantoos Dec 9, 2024
fe73c70
Fix test_path.py tests
ghantoos Dec 9, 2024
cda6786
Fix test_ps2.py tests
ghantoos Dec 12, 2024
7a18d59
Fix test_scripts.py tests
ghantoos Dec 12, 2024
2db1973
Fix test_security.py tests
ghantoos Dec 12, 2024
5e65eba
Fix test_signals.py tests
ghantoos Dec 13, 2024
1450194
Pylint fixes
ghantoos Dec 13, 2024
cfa6591
Merge branch 'master' into f/lexer_parser
ghantoos Dec 13, 2024
e3ae2a5
Cleanup commented code
ghantoos Dec 13, 2024
c5c76c9
Add parser tests
ghantoos Dec 17, 2024
8b4a0ea
Update .gitignore and fix parser variable naming conventions
ghantoos Feb 27, 2026
3efc4ef
Improve command parsing and execution handling; Fix tests
ghantoos Feb 27, 2026
ef73e86
Add security-focused unit and functional tests for command parsing an…
ghantoos Feb 27, 2026
f5d9f84
Enhance exec_cmd to prevent non-interactive shell startup file inject…
ghantoos Feb 27, 2026
6e47332
Reject forbidden environment variable assignments in command prefixes…
ghantoos Feb 27, 2026
56928f2
Fix pylint/flake8
ghantoos Feb 27, 2026
20adcbf
Fix support for noexec library preloading and enhance exec_cmd
ghantoos Feb 27, 2026
716facb
Bump version to 1.0.0rc1
ghantoos Feb 27, 2026
cf53e78
Enhance job control: Implement Ctrl+Z handling for foreground jobs an…
ghantoos Feb 27, 2026
91ae2dc
Fix completion
ghantoos Feb 27, 2026
0d3ee3f
Add tests
ghantoos Feb 27, 2026
8b6edec
Add tests for allowed shell escape and direct sudo execution in cmd_p…
ghantoos Feb 27, 2026
93d5820
Fix sudo support
ghantoos Feb 28, 2026
ebdcf86
Reject 'all' for allowed_shell_escape in configuration to prevent glo…
ghantoos Feb 28, 2026
262709a
Fix allowed_shell_escape to use "lshell -c" locally
ghantoos Feb 28, 2026
629c87a
Enhance test coverage for command execution and SSH handling in lshell
ghantoos Feb 28, 2026
1fc2f53
Add tests for executing login scripts with bash invocation in cmdloop
ghantoos Feb 28, 2026
cf6b645
Enhance scp handling in CheckConfig for WinSCP compatibility and add …
ghantoos Feb 28, 2026
2445287
Fix pylint
ghantoos Feb 28, 2026
2cb0007
Fix warning for unknown syntax and forbidden commands, updating docum…
ghantoos Mar 1, 2026
38c614d
Remove Alpine docker+tests
ghantoos Mar 1, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ etc/test.conf
build/
dist/
test.lsh
.pylint_cache/
3 changes: 1 addition & 2 deletions .pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ ignore-patterns=^\.#
# (useful for modules/projects where namespaces are manipulated during runtime
# and thus existing member attributes cannot be deduced by static analysis). It
# supports qualified module names, as well as Unix pattern matching.
ignored-modules=
ignored-modules=pyparsing,setuptools,setuptools.command.install,pexpect

# Python code to execute, usually for sys.path manipulation such as
# pygtk.require().
Expand Down Expand Up @@ -101,7 +101,6 @@ recursive=no

# When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages.
suggestion-mode=yes

# Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code.
Expand Down
18 changes: 10 additions & 8 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,29 @@ RUN \
# For Debian/Ubuntu
if [ -f /etc/debian_version ]; then \
apt-get update && \
apt-get install -y python3 python3-pip git flake8 pylint python3-pytest python3-pexpect python3-setuptools vim procps && \
apt-get install -y python3 python3-pip git flake8 pylint python3-pytest python3-pexpect python3-setuptools python3-pyparsing vim procps sudo && \
apt-get clean; \
useradd -m -d /home/testuser -s /bin/bash testuser; \
echo 'testuser:password' | chpasswd; \
echo 'testuser ALL=(ALL:ALL) ALL' > /etc/sudoers.d/testuser && chmod 0440 /etc/sudoers.d/testuser; \
# For Fedora
elif [ -f /etc/fedora-release ]; then \
dnf install -y python3 python3-pip python3-pytest git flake8 pylint python3-pexpect python3-setuptools vim; \
dnf install -y python3 python3-pip python3-pytest git flake8 pylint python3-pexpect python3-setuptools python3-pyparsing vim sudo; \
useradd -m -d /home/testuser -s /bin/bash testuser; \
echo 'testuser:password' | chpasswd; \
echo 'testuser ALL=(ALL:ALL) ALL' > /etc/sudoers.d/testuser && chmod 0440 /etc/sudoers.d/testuser; \
# For CentOS
elif [ -f /etc/centos-release ]; then \
# Update CentOS repository to use vault.centos.org
sed -i 's/mirrorlist/#mirrorlist/g' /etc/yum.repos.d/CentOS-* && \
sed -i 's|#baseurl=http://mirror.centos.org|baseurl=http://vault.centos.org|g' /etc/yum.repos.d/CentOS-* && \
yum install -y python3 python3-pip python3-pytest git vim && \
yum install -y python3 python3-pip python3-pytest git vim sudo && \
yum install -y python3-devel gcc && \
python3 -m pip install flake8 pylint pexpect setuptools; \
python3 -m pip install flake8 pylint pexpect setuptools pyparsing; \
yum clean all; \
useradd -m -d /home/testuser -s /bin/bash testuser; \
# For Alpine
elif [ -f /etc/alpine-release ]; then \
apk add --no-cache --upgrade python3 py3-pip py3-pytest py3-flake8 py3-pylint py3-pexpect py3-setuptools grep vim; \
addgroup -S testuser && adduser -S testuser -G testuser; \
echo 'testuser:password' | chpasswd; \
echo 'testuser ALL=(ALL:ALL) ALL' > /etc/sudoers.d/testuser && chmod 0440 /etc/sudoers.d/testuser; \
fi

# Set permissions for the user to access /app
Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ This will:

Commands that do not include arguments (e.g., `ls`) can be used with any arguments, while commands specified with arguments (e.g., `echo asd`) must be used exactly as specified.

For local executables, add the relative path explicitly in `allowed` (for example `./deploy.sh`).
This also enables `./` command-name completion from the allowed local entries.

#### User profiles

A [default] profile is available for all users using lshell. Nevertheless, you can create a [username] section or a [grp:groupname] section to customize users' preferences.
Expand All @@ -124,7 +127,10 @@ For example User 'foo' and user 'bar' both belong to the 'users' UNIX group:
- User 'bar':
- must be able to access /etc and /usr but not /usr/local
- is allowed default commands plus 'ping' minus 'ls'
- strictness is set to 1 (meaning he is not allowed to type an unknown command)
- strictness is set to 1 (unknown syntax/commands decrement warning_counter)

`warning_counter` is decremented on forbidden command/path/character attempts.
When `strict` is enabled, unknown syntax is also counted.

In this case, my configuration file will look something like this:

Expand Down Expand Up @@ -168,10 +174,10 @@ More information can be found in the manpage: `man -l man/lshell.1` or `man lshe

## Running Tests in Docker Containers

You can run the tests in parallel across multiple Linux distributions using Docker Compose. This is helpful for ensuring compatibility and consistency across environments. The following command will launch test services for Ubuntu, Debian, Fedora, and Alpine distributions simultaneously:
You can run the tests in parallel across multiple Linux distributions using Docker Compose. This is helpful for ensuring compatibility and consistency across environments. The following command will launch test services for Ubuntu, Debian, and Fedora distributions simultaneously:

```bash
docker-compose up ubuntu_tests debian_tests fedora_tests alpine_tests
docker-compose up ubuntu_tests debian_tests fedora_tests
```

Each service will run in parallel and execute the `pytest`, `pylint`, and `flake8` tests specified in the docker-compose.yml.
Expand Down
19 changes: 13 additions & 6 deletions bin/lshell
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,20 @@ def main():

signal.signal(signal.SIGTSTP, disable_ctrl_z)

cli = ShellCmd(userconf, args)
try:
cli = ShellCmd(userconf, args)
cli.cmdloop()

except (KeyboardInterrupt, EOFError):
sys.stdout.write("\nExited on user request\n")
sys.exit(0)
while True:
try:
cli.cmdloop()
break
except KeyboardInterrupt:
# Keep interactive sessions alive when Ctrl+C races outside
# command-specific handlers.
sys.stdout.write("\n")
continue
except EOFError:
sys.stdout.write("\nExited on user request\n")
sys.exit(0)
except LshellTimeOut:
userconf["logpath"].error("Timer expired")
sys.stdout.write("\nTime is up.\n")
Expand Down
41 changes: 6 additions & 35 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ services:
DISTRO: "ubuntu:latest"
image: lshell-ubuntu
container_name: lshell-ubuntu
entrypoint: ["lshell"]
entrypoint: ["lshell", "--config", "/app/etc/lshell.conf"]
command: ""
volumes:
- .:/app
Expand All @@ -25,7 +25,7 @@ services:
DISTRO: "debian:latest"
image: lshell-debian
container_name: lshell-debian
entrypoint: ["lshell"]
entrypoint: ["lshell", "--config", "/app/etc/lshell.conf"]
command: ""
volumes:
- .:/app
Expand All @@ -40,7 +40,7 @@ services:
DISTRO: "fedora:latest"
image: lshell-fedora
container_name: lshell-fedora
entrypoint: ["lshell"]
entrypoint: ["lshell", "--config", "/app/etc/lshell.conf"]
command: ""
volumes:
- .:/app
Expand All @@ -55,29 +55,13 @@ services:
DISTRO: "centos:8"
image: lshell-centos
container_name: lshell-centos
entrypoint: ["lshell"]
entrypoint: ["lshell", "--config", "/app/etc/lshell.conf"]
command: ""
volumes:
- .:/app
environment:
- PYTHONPATH=/app

alpine:
build:
context: .
dockerfile: Dockerfile
args:
DISTRO: "alpine:latest"
image: lshell-alpine
container_name: lshell-alpine
entrypoint: ["lshell"]
command: ""
volumes:
- .:/app
environment:
- PYTHONPATH=/app


#################################################
## Run linting and tests for each distribution ##
#################################################
Expand Down Expand Up @@ -116,7 +100,7 @@ services:
args:
DISTRO: "fedora:latest"
container_name: fedora_tests
command: "pytest && pylint lshell && flake8 lshell"
command: "pytest; pylint lshell; flake8 lshell"
volumes:
- .:/app
environment:
Expand All @@ -129,20 +113,7 @@ services:
args:
DISTRO: "centos:8"
container_name: centos_tests
command: "sh -c 'pytest-3 && pylint lshell && pyflake lshell'"
volumes:
- .:/app
environment:
- PYTHONPATH=/app

alpine_tests:
build:
context: .
dockerfile: Dockerfile
args:
DISTRO: "alpine:latest"
container_name: alpine_tests
command: "sh -c 'pytest && pylint lshell && flake8 lshell'"
command: "sh -c 'pytest-3; pylint lshell; pyflake lshell'"
volumes:
- .:/app
environment:
Expand Down
12 changes: 7 additions & 5 deletions etc/lshell.conf
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,16 @@ loglevel : 2
[default]
## a list of the allowed commands without execution privileges or 'all' to
## allow all commands in user's PATH
## local commands must be explicitly listed with their relative path
## (e.g. './backup.sh')
##
## if sudo(8) is installed and sudo_noexec.so is available, it will be loaded
## before running every command, preventing it from running further commands
## itself. If not available, beware of commands like vim/find/more/etc. that
## will allow users to execute code (e.g. /bin/sh) from within the application,
## thus easily escaping lshell. See variable 'path_noexec' to use an alternative
## path to library.
allowed : ['ls','echo','ll','vim','tail','sleep','touch','mkdir']
allowed : ['ls','echo','ll','vim','tail','sleep','touch','mkdir','cat','export']
#allowed : ['echo test'] # this will allow only the command 'echo test'

## A list of the allowed commands that are permitted to execute other
Expand All @@ -63,7 +65,7 @@ forbidden : [';','&', '|','`','>','<', '$(','${']

## a list of allowed command to use with sudo(8)
## if set to ´all', all the 'allowed' commands will be accessible through sudo(8)
#sudo_commands : ['ls', 'more']
sudo_commands : ['ls', 'more']

## number of warnings when user enters a forbidden value before getting
## exited from lshell, set to -1 to disable.
Expand Down Expand Up @@ -124,9 +126,9 @@ prompt : "\033[91m%u\033[97m@\033[96m%h\033[0m"
## list of command allowed to execute over ssh (e.g. rsync, rdiff-backup, etc.)
#overssh : ['ls', 'rsync']

## logging strictness. If set to 1, any unknown command is considered as
## forbidden, and user's warning counter is increased. If set to 0, command is
## considered as unknown, and user is only warned (i.e. *** unknown syntax)
## logging strictness. If set to 1, unknown syntax/commands are considered
## forbidden and decrement warning_counter (which can kick the user out).
## If set to 0, they are reported as unknown syntax only.
strict : 0

## force files sent through scp to a specific directory
Expand Down
90 changes: 67 additions & 23 deletions lshell/builtincmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@
# Store background jobs
BACKGROUND_JOBS = []

builtins_list = [
"cd",
"clear",
"exit",
"export",
"history",
"lpath",
"lsudo",
"help",
"fg",
"bg",
"jobs",
"source",
]


def cmd_lpath(conf):
"""lists allowed and forbidden path"""
Expand Down Expand Up @@ -149,22 +164,19 @@ def cmd_cd(directory, conf):

def check_background_jobs():
"""Check the status of background jobs and print a completion message if done."""
global BACKGROUND_JOBS
updated_jobs = []
active_jobs = []
for idx, job in enumerate(BACKGROUND_JOBS, start=1):
if job.poll() is None:
# Process is still running
updated_jobs.append((idx, job.args, job.pid))
else:
# Process has finished
status = "Done" if job.returncode == 0 else "Failed"
args = " ".join(job.args)
# only print if the job has not been interrupted by the user
if job.returncode != -2:
print(f"[{idx}]+ {status} {args}")
active_jobs.append(job)
continue

status = "Done" if job.returncode == 0 else "Failed"
args = _job_command(job)
# only print if the job has not been interrupted by the user
if job.returncode != -2:
print(f"[{idx}]+ {status} {args}")

# Remove the job from the list of background jobs
BACKGROUND_JOBS.pop(idx - 1)
BACKGROUND_JOBS[:] = active_jobs


def get_job_status(job):
Expand All @@ -178,18 +190,26 @@ def get_job_status(job):
return status


def _job_command(job):
"""Return the original command line for a tracked job."""
return getattr(job, "lshell_cmd", " ".join(job.args))


def jobs():
"""Return a list of background jobs."""
global BACKGROUND_JOBS
joblist = []
for idx, job in enumerate(BACKGROUND_JOBS, start=1):
active_jobs = []
for job in BACKGROUND_JOBS:
if job.poll() is not None:
continue

active_jobs.append(job)
idx = len(active_jobs)
status = get_job_status(job)
if status in ["Stopped", "Killed"]:
if job.poll() is not None:
BACKGROUND_JOBS.pop(idx - 1)
continue
cmd = " ".join(job.args)
cmd = _job_command(job)
joblist.append([idx, status, cmd])

BACKGROUND_JOBS[:] = active_jobs
return joblist


Expand Down Expand Up @@ -218,8 +238,6 @@ def cmd_jobs():

def cmd_bg_fg(job_type, job_id):
"""Resume a backgrounded job."""

global BACKGROUND_JOBS
if job_type == "bg":
print("lshell: bg not supported")
return 1
Expand All @@ -243,19 +261,45 @@ def cmd_bg_fg(job_type, job_id):
job = BACKGROUND_JOBS[job_id - 1]
if job.poll() is None:
if job_type == "fg":
class CtrlZForeground(Exception):
"""Raised when the foreground job is suspended with Ctrl+Z."""

pass

def handle_sigtstp(signum, frame):
"""Suspend the foreground job and keep/update its jobs list entry."""
if job.poll() is None:
os.killpg(os.getpgid(job.pid), signal.SIGSTOP)
if job in BACKGROUND_JOBS:
current_job_id = BACKGROUND_JOBS.index(job) + 1
else:
BACKGROUND_JOBS.append(job)
current_job_id = len(BACKGROUND_JOBS)
sys.stdout.write(
f"\n[{current_job_id}]+ Stopped {_job_command(job)}\n"
)
sys.stdout.flush()
raise CtrlZForeground()

previous_sigtstp_handler = signal.getsignal(signal.SIGTSTP)
try:
print(" ".join(job.args))
signal.signal(signal.SIGTSTP, handle_sigtstp)
print(_job_command(job))
# Bring it to the foreground and wait
os.killpg(os.getpgid(job.pid), signal.SIGCONT)
job.wait()
# Remove the job from the list if it has completed
if job.poll() is not None:
BACKGROUND_JOBS.pop(job_id - 1)
return 0
except CtrlZForeground:
return 0
except KeyboardInterrupt:
os.killpg(os.getpgid(job.pid), signal.SIGINT)
BACKGROUND_JOBS.pop(job_id - 1)
return 130
finally:
signal.signal(signal.SIGTSTP, previous_sigtstp_handler)
# bg not supported at the moment
# elif job_type == "bg":
# print(f"lshell: bg not supported")
Expand Down
Loading