Skip to content
Merged
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
140 changes: 60 additions & 80 deletions .github/workflows/pypi-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,109 +2,89 @@ name: Publish Python 🐍 distribution 📦 to PyPI

on:
push:
branches:
- pre-release
tags:
- "*"
- "*"

permissions:
contents: read

jobs:
validate-release-channel:
name: Validate release channel
publish:
runs-on: ubuntu-latest
outputs:
branch_channel: ${{ steps.validate.outputs.branch_channel }}
release_type: ${{ steps.validate.outputs.release_type }}
permissions:
id-token: write

steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate tag and branch channel
id: validate
fetch-depth: 2
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Decide whether to publish
id: decide
env:
TAG_NAME: ${{ github.ref_name }}
TAG_SHA: ${{ github.sha }}
REF_TYPE: ${{ github.ref_type }}
REF_NAME: ${{ github.ref_name }}
SHA: ${{ github.sha }}
BEFORE_SHA: ${{ github.event.before }}
run: |
set -euo pipefail
git fetch --no-tags origin main pre-release
TAG_VERSION="${TAG_NAME}"
PACKAGE_VERSION="$(python3 -c 'from lshell.variables import __version__; print(__version__)')"

if [[ "${TAG_VERSION}" != "${PACKAGE_VERSION}" ]]; then
echo "Tag/version mismatch: tag=${TAG_VERSION}, package=${PACKAGE_VERSION}" >&2
exit 1
fi
CURRENT_VERSION="$(python3 -c 'from lshell.variables import __version__; print(__version__)')"
echo "version=${CURRENT_VERSION}" >> "${GITHUB_OUTPUT}"

if [[ "${TAG_NAME}" == *rc* ]]; then
if git merge-base --is-ancestor "${TAG_SHA}" "origin/pre-release"; then
echo "branch_channel=pre-release" >> "${GITHUB_OUTPUT}"
echo "release_type=release-candidate" >> "${GITHUB_OUTPUT}"
# Official releases: tag is required and must match package version.
if [[ "${REF_TYPE}" == "tag" ]]; then
if [[ "${REF_NAME}" == "${CURRENT_VERSION}" ]]; then
echo "should_publish=true" >> "${GITHUB_OUTPUT}"
echo "reason=tag_release" >> "${GITHUB_OUTPUT}"
else
echo "Tag ${TAG_NAME} is an RC but is not based on pre-release." >&2
exit 1
fi
else
if git merge-base --is-ancestor "${TAG_SHA}" "origin/main"; then
echo "branch_channel=main" >> "${GITHUB_OUTPUT}"
echo "release_type=stable" >> "${GITHUB_OUTPUT}"
else
echo "Tag ${TAG_NAME} is stable but is not based on main." >&2
exit 1
echo "should_publish=false" >> "${GITHUB_OUTPUT}"
echo "reason=tag_version_mismatch" >> "${GITHUB_OUTPUT}"
fi
exit 0
fi

build:
name: Build distribution 📦
needs:
- validate-release-channel
runs-on: ubuntu-latest
# Auto RC release from pre-release only when version file changed.
if [[ "${REF_NAME}" != "pre-release" ]]; then
echo "should_publish=false" >> "${GITHUB_OUTPUT}"
echo "reason=not_pre_release_branch" >> "${GITHUB_OUTPUT}"
exit 0
fi

steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install pypa/build
run: >-
python3 -m
pip install
build
--user
- name: Build a binary wheel and a source tarball
run: python3 -m build
- name: Store the distribution packages
uses: actions/upload-artifact@v4
with:
name: python-package-distributions
path: dist/
if [[ "${CURRENT_VERSION}" != *rc* ]]; then
echo "should_publish=false" >> "${GITHUB_OUTPUT}"
echo "reason=not_rc_version" >> "${GITHUB_OUTPUT}"
exit 0
fi

publish-to-pypi:
name: >-
Publish Python 🐍 distribution 📦 to PyPI
needs:
- validate-release-channel
- build
runs-on: ubuntu-latest
if [[ -z "${BEFORE_SHA}" || "${BEFORE_SHA}" =~ ^0+$ ]]; then
echo "should_publish=false" >> "${GITHUB_OUTPUT}"
echo "reason=missing_before_sha" >> "${GITHUB_OUTPUT}"
exit 0
fi

environment:
name: pypi
url: https://pypi.org/p/limited-shell
permissions:
id-token: write # IMPORTANT: mandatory for trusted publishing
if ! git diff --name-only "${BEFORE_SHA}..${SHA}" | grep -qx "lshell/variables.py"; then
echo "should_publish=false" >> "${GITHUB_OUTPUT}"
echo "reason=version_file_not_changed" >> "${GITHUB_OUTPUT}"
exit 0
fi

steps:
- name: Download all the dists
uses: actions/download-artifact@v4
with:
name: python-package-distributions
path: dist/
- name: Print release channel
env:
RELEASE_TYPE: ${{ needs.validate-release-channel.outputs.release_type }}
BRANCH_CHANNEL: ${{ needs.validate-release-channel.outputs.branch_channel }}
echo "should_publish=true" >> "${GITHUB_OUTPUT}"
echo "reason=rc_version_file_changed" >> "${GITHUB_OUTPUT}"
- name: Decision summary
run: |
echo "should_publish=${{ steps.decide.outputs.should_publish }}"
echo "version=${{ steps.decide.outputs.version }}"
echo "reason=${{ steps.decide.outputs.reason }}"
- name: Build distribution 📦
if: steps.decide.outputs.should_publish == 'true'
run: |
echo "Publishing ${GITHUB_REF_NAME} as ${RELEASE_TYPE} from ${BRANCH_CHANNEL}"
python3 -m pip install --upgrade build
python3 -m build
- name: Publish distribution 📦 to PyPI
if: steps.decide.outputs.should_publish == 'true'
uses: pypa/gh-action-pypi-publish@release/v1
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ Contact: [ghantoos@ghantoos.org](mailto:ghantoos@ghantoos.org)
- Changed `harden-init` default output path to `/etc/lshell.d/<profile>.conf`.
- Enabled `include_dir : /etc/lshell.d/*.conf` in the default `/etc/lshell.conf` template.

### v0.11.1rc3 18/03/2026
- Added runtime containment `max_sessions_per_user` with lock-protected per-user session accounting and startup enforcement.
- Added runtime containment `max_background_jobs` enforcement for interactive `&` job creation with denial audit reasons.
- Added runtime containment `command_timeout` to terminate overlong foreground/background commands and report timeout denials.
- Added runtime containment `max_processes` with best-effort `RLIMIT_NPROC` enforcement for spawned commands.

### v0.11.0 10/03/2026
- Reworked command parsing with a new `pyparsing`-based parser for more reliable command handling.
- Added policy diagnostics and built-ins: `policy-show`, `policy-path`, and `policy-sudo`.
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,13 +148,33 @@ Key settings to review:
- `messages`
- `warning_counter`, `strict`
- `umask`
- runtime containment: `max_sessions_per_user`, `max_background_jobs`, `command_timeout`, `max_processes`

CLI overrides are supported, for example:

```bash
lshell --config /path/to/lshell.conf --log /var/log/lshell --umask 0077
```

### Runtime containment limits

Runtime limits are optional and disabled by default when set to `0`.

```ini
max_sessions_per_user : 2
max_background_jobs : 4
command_timeout : 30
max_processes : 64
```

Operational notes:

- `max_sessions_per_user` is tracked with lock-protected session records; stale entries are cleaned automatically.
- `max_background_jobs` denies new `&` jobs once the configured active count is reached.
- `command_timeout` enforces a per-command wall-clock timeout (foreground and background commands).
- `max_processes` is applied via POSIX `RLIMIT_NPROC` on spawned command processes.
- Best practice: keep `command_timeout` enabled whenever `max_processes` is strict (especially `1`).

### Best practices

- Prefer an explicit `allowed` allow-list instead of `'all'`.
Expand Down
12 changes: 12 additions & 0 deletions etc/lshell.conf
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,18 @@ prompt : "\033[91m%u\033[97m@\033[96m%h\033[0m"
## a value in seconds for the session timer
#timer : 5

## Runtime containment limits (disabled by default when set to 0):
## Max concurrent lshell sessions for this user.
max_sessions_per_user : 0
## Max active background jobs (`&`) tracked in this session.
max_background_jobs : 0
## Wall-clock timeout in seconds per executed command.
command_timeout : 0
## Max processes for each spawned command (RLIMIT_NPROC).
## Best practice: keep command_timeout enabled whenever
## max_processes is strict (especially 1).
max_processes : 0

## list of paths to restrict where the user can operate
## warning: commands like vi and less can bypass this restriction
#path : ['/etc','/var/log','/var/lib']
Expand Down
27 changes: 24 additions & 3 deletions lshell/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,27 @@ def pop_decision_reason(conf, default="policy evaluation failed"):

def log_command_event(conf, command, allowed, reason, level=None):
"""Emit one ECS-aligned command authorization event."""
log_security_event(
conf,
action="command_authorization",
allowed=allowed,
reason=reason,
command=command,
level=level,
message="lshell command authorization decision",
)


def log_security_event(
conf,
action,
allowed,
reason,
command="",
level=None,
message="lshell security decision",
):
"""Emit one ECS-aligned runtime security event."""
if not enabled(conf):
return

Expand All @@ -106,18 +127,18 @@ def log_command_event(conf, command, allowed, reason, level=None):
log_level = getattr(logging, log_method.upper(), logging.INFO)
logger.log(
log_level,
"lshell command authorization decision",
message,
extra={
"session_id": str(conf.get("session_id", "")),
"source_ip": _source_ip(),
"username": str(conf.get("username", "")),
"event_kind": "event",
"event_category": ["authentication", "process"],
"event_type": ["access"],
"event_action": "command_authorization",
"event_action": str(action),
"event_outcome": "success" if allowed else "failure",
"event_reason": str(reason),
"process_command_line": str(command),
"process_command_line": str(command or ""),
"lshell_security_allowed": bool(allowed),
},
)
15 changes: 15 additions & 0 deletions lshell/builtincmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@
]


def _cancel_job_timeout(job):
"""Cancel a watchdog timer attached to a background job, if any."""
timer = getattr(job, "lshell_timeout_timer", None)
if timer is not None:
timer.cancel()


def cmd_lpath(conf):
"""Show path policy in a concise, readable format."""
current_dir = os.path.realpath(os.getcwd())
Expand Down Expand Up @@ -200,6 +207,11 @@ def check_background_jobs():
active_jobs.append(job)
continue

_cancel_job_timeout(job)
if getattr(job, "lshell_timeout_triggered", False):
print(f"[{idx}]+ Timed Out {_job_command(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
Expand All @@ -211,6 +223,8 @@ def check_background_jobs():

def get_job_status(job):
"""Return the status of a background job."""
if getattr(job, "lshell_timeout_triggered", False):
return "Timed Out"
if job.poll() is None:
status = "Stopped"
elif job.poll() == 0:
Expand All @@ -231,6 +245,7 @@ def jobs():
active_jobs = []
for job in BACKGROUND_JOBS:
if job.poll() is not None:
_cancel_job_timeout(job)
continue

active_jobs.append(job)
Expand Down
11 changes: 11 additions & 0 deletions lshell/checkconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from lshell import builtincmd
from lshell import configschema
from lshell import audit
from lshell import containment


class CheckConfig:
Expand Down Expand Up @@ -568,6 +569,10 @@ def get_config_user(self):
"policy_commands",
"quiet",
"security_audit_json",
"max_sessions_per_user",
"max_background_jobs",
"command_timeout",
"max_processes",
]:
try:
if len(self.conf_raw[item]) == 0:
Expand Down Expand Up @@ -610,6 +615,12 @@ def get_config_user(self):
self.log.critical("lshell: config: 'prompt_short' must be 0, 1, or 2")
sys.exit(1)

try:
containment.validate_runtime_config(self.conf)
except ValueError as exception:
self.log.critical(f"lshell: config: {exception}")
sys.exit(1)

self.conf["username"] = self.user

if "umask" in self.conf_raw:
Expand Down
Loading
Loading