diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 323a487..2e58a42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,10 +11,13 @@ concurrency: cancel-in-progress: true jobs: - # Static analysis: shellcheck every shell script. The `error` severity gate - # is a hard failure (the repo is clean at this level today). Warnings are - # reported separately and do not fail the build, so they can be cleaned up - # incrementally without blocking merges. + # Static analysis: shellcheck every shell script. The repo is clean at both + # `error` and `warning` severity, so both are hard gates -- this ratchets the + # warning count at zero, preventing warnings from silently accumulating. + # Genuinely-unavoidable findings are silenced locally with a `# shellcheck + # disable=SCxxxx` directive (with a justification) rather than by lowering the + # gate. The test jobs `needs: shellcheck`, so a lint failure short-circuits the + # (more expensive) matrix instead of running it in parallel regardless. shellcheck: name: ShellCheck runs-on: ubuntu-latest @@ -24,25 +27,19 @@ jobs: - name: Install shellcheck run: sudo apt-get update && sudo apt-get install -y shellcheck - - name: Collect shell scripts - id: collect + - name: ShellCheck (warning severity, blocking) run: | # ell and the launcher have no .sh extension; include them explicitly. - files="$(git ls-files '*.sh' 'ell' | tr '\n' ' ')" - echo "files=${files}" >> "${GITHUB_OUTPUT}" - echo "Scripts to check: ${files}" - - - name: ShellCheck (errors, blocking) - run: shellcheck -S error ${{ steps.collect.outputs.files }} - - - name: ShellCheck (warnings, non-blocking) - continue-on-error: true - run: shellcheck -S warning ${{ steps.collect.outputs.files }} + # Use NUL-delimited paths piped to xargs -0 so filenames containing + # spaces or newlines are handled correctly (rather than word-splitting + # an unquoted, space-joined list). + git ls-files -z '*.sh' 'ell' | xargs -0 shellcheck -S warning # Run the full test suite across the oldest and current supported bash # versions using the project's own containerised entry point. tests: name: Tests (bash ${{ matrix.bash }}) + needs: shellcheck runs-on: ubuntu-latest strategy: fail-fast: false @@ -57,3 +54,132 @@ jobs: # curl and a global launcher, so no extra setup is needed here. - name: Run test suite run: bash tests/entry.sh + + # Run the suite with mawk as `awk`. Debian/Ubuntu default to mawk (not the + # gawk/busybox awk the container jobs use), and render_to_text.awk is written + # to be portable across awk implementations -- this actually exercises that. + tests-mawk: + name: Tests (mawk) + needs: shellcheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install mawk and make it the default awk + run: | + sudo apt-get update && sudo apt-get install -y mawk + # Point /usr/bin/awk at mawk for this run. + sudo update-alternatives --set awk /usr/bin/mawk + awk -W version 2>&1 | head -1 || awk --version 2>&1 | head -1 || true + + - name: Run test suite + run: bash tests/entry.sh + + # Run the suite on macOS to exercise the BSD toolchain (BSD awk/sed/stat/ + # mktemp, macOS curl) the code is written to support. macOS ships bash 3.2, + # which ell rejects, so install a supported bash; GNU `timeout` is absent on + # BSD, so provide it from coreutils (as gtimeout) for the test harness. + tests-macos: + name: Tests (macOS / BSD tools) + needs: shellcheck + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Install bash and coreutils (for timeout) + run: | + brew install bash coreutils + # Expose gtimeout as `timeout` for the test harness, on PATH ahead of + # anything else. The suite otherwise uses the system BSD tools. + mkdir -p "${HOME}/bin" + ln -sf "$(brew --prefix coreutils)/libexec/gnubin/timeout" "${HOME}/bin/timeout" + echo "${HOME}/bin" >> "${GITHUB_PATH}" + # Put the newer bash ahead of the system 3.2. + echo "$(brew --prefix)/bin" >> "${GITHUB_PATH}" + + - name: Show tool versions + run: | + bash --version | head -1 + command -v timeout && timeout --version | head -1 + awk --version 2>/dev/null | head -1 || echo "awk: BSD (no --version)" + + - name: Run test suite + run: bash tests/entry.sh + + # Run the suite on Windows under a real MSYS2 environment (not Git Bash). + # MSYS2 provides a fuller POSIX runtime than Git Bash -- consistent msys-2.0 + # runtime, ACL-backed permissions, real symlinks, and util-linux (script) -- + # so more of the suite is expected to work here than under Git Bash. Tools are + # installed via pacman. This is currently informational (continue-on-error) + # until it is proven stable, then it can become a hard gate. + tests-msys2: + name: Tests (Windows / MSYS2) + needs: shellcheck + runs-on: windows-latest + continue-on-error: true + defaults: + run: + shell: msys2 {0} + steps: + - uses: actions/checkout@v4 + + - name: Set up MSYS2 + uses: msys2/setup-msys2@v2 + with: + msystem: MSYS + update: true + # bash comes with MSYS2; add the tools the suite uses. coreutils + # provides `timeout`; util-linux provides `script` for record tests; + # python is used by the real-PTY record test. + install: >- + bash + coreutils + curl + gawk + sed + grep + util-linux + python + + - name: Show tool versions + run: | + bash --version | head -1 + command -v timeout && timeout --version | head -1 + command -v script && echo "script: present" + awk --version 2>/dev/null | head -1 || true + + - name: Run test suite + run: bash tests/entry.sh + + # Run the suite on Windows under Git Bash (the default `shell: bash` on the + # windows runner). Git Bash is a deliberately limited environment: over NTFS + # it cannot create genuinely group/world-writable files or (by default) real + # symlinks, and it ships no `script`. Tests that depend on those capabilities + # detect the limitation at runtime and SKIP (see docs/Configuration.md, + # "Windows"); for a fuller POSIX environment use the tests-msys2 job above. + # This job stays informational: it reports what works rather than blocking. + tests-windows: + name: Tests (Windows / Git Bash, informational) + needs: shellcheck + runs-on: windows-latest + continue-on-error: true + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + + - name: Show environment + run: | + bash --version | head -1 + uname -a || true + for t in curl awk sed stat mktemp script stty tty date chmod; do + if command -v "${t}" >/dev/null 2>&1; then + printf '%-8s %s\n' "${t}" "$(command -v "${t}")" + else + printf '%-8s MISSING\n' "${t}" + fi + done + + - name: Run test suite + run: bash tests/entry.sh diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0a3797a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,101 @@ +# Changelog + +All notable changes to this project are documented here. The format is based on +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +This is a large hardening pass focused on security, robustness, and testing. + +### Security + +- Templates are no longer rendered through the shell. Previously template + contents were run through `eval`, so a template (or a prompt / recorded + context interpolated into one) could execute arbitrary commands. Rendering is + now a strict allowlist substitution with JSON escaping and no shell + evaluation. +- `-O KEY=VALUE` no longer runs through `eval`. It is assigned directly and the + key is validated, closing a command-injection hole (`-O 'X=$(cmd)'`). + Note: as a consequence of the safe, allowlist-only template renderer, variables + set via `-O` are exported into the environment but are **no longer substituted + into templates** (the old `eval` renderer did expand them). Only the fixed + placeholder allowlist is rendered. +- Config files are only sourced when trusted: owned by the current user (or + root) and not group-/world-writable. This blocks the "hostile `.ellrc` in the + current directory" arbitrary-code-execution vector. Refusals are now reported + at a visible log level. +- `ELL_API_STYLE` and template names are validated as single path segments, so + they cannot be used to source/load arbitrary files by path traversal. +- The recorded terminal context (`SHELL_CONTEXT`) now passes through the + `post_input` hooks, so redaction applies to it too, not just the prompt. +- The API key is kept out of the process table: it is passed to curl via a + `--config` file (mode 0600, removed after use) instead of a `--header` + argument, and it is no longer logged at debug level. +- ell refuses to send credentials to a plaintext `http://` URL (loopback + excepted); override with `ELL_ALLOW_INSECURE_URL=true`. +- The bundled redaction plugin now ships **disabled by default** (its hook has a + `.disabled` suffix); enable it by removing the suffix. + +### Fixed + +- Streaming completions that finish abnormally (e.g. `length` / `MAX_TOKENS`) + are now detected and reported as failures instead of silent success + (openai read the finish reason from the wrong path). +- A value-taking option given with no argument (e.g. `ell -m`) no longer causes + an infinite loop; it exits with a usage error. +- Interactive mode exits cleanly on EOF (Ctrl-D) instead of looping forever, and + a final unterminated line is still processed. The exit hint now names Ctrl-D. +- `-o`/`--output` is accepted as documented (previously only `--output-file`), + and output is no longer redirected to a file in record/interactive mode (an + always-true comparison was fixed). +- The `--record` "already enabled" guard now works, and `ELL_RECORD` is + exported like the other flags. +- Record mode re-execs ell by an absolute, shell-quoted path, so it works when + ell is run in place or is not on `PATH`. +- Template resolution rejects path-traversal names and accepts an + `ELL_TEMPLATE_PATH` with or without a trailing slash; plugin-hook discovery no + longer breaks on paths containing spaces/newlines. +- Missing `ELL_API_URL` now fails early with a clear, actionable message + (EX_CONFIG) instead of an opaque curl error; a missing `ELL_API_KEY` warns. +- curl failures are reported with a human-readable explanation (DNS, connection + refused, timeout, TLS, …) rather than a bare exit code. +- Functions are exported with `export -f` rather than as empty variables. +- Bash 4.1 compatibility is restored (the version gate, docs and CI now + consistently target 4.1). + +### Changed + +- HTTP requests go through a shared `ell_curl` wrapper with connection/overall + timeouts (`ELL_CONNECT_TIMEOUT`, `ELL_MAX_TIME`) and HTTP error handling + (`--fail-with-body`), so a stalled server no longer hangs and a 4xx/5xx no + longer surfaces as a vague "Unexpected format". +- The openai and gemini backends share their non-streaming path, end-of-stream + reporting and PIPESTATUS handling via `helpers/backend_common.sh`; each keeps + only its provider-specific streaming parser. +- `helpers/http.sh` moved out of `llm_backends/` for consistency with the other + helpers. +- The default log level is unified across entry points. + +### Performance + +- The pure-bash JSON parser copies runs of ordinary string characters in one + operation and no longer forks a subshell per key lookup. +- The gemini streaming parser tracks JSON object boundaries instead of + re-parsing the whole accumulated buffer per line (was O(n²)). +- Streaming loops use bash builtins instead of a `grep`/`cut`/`tr` subprocess + per line, and logging no longer forks `date`/`basename` per line. + +### Added + +- Documentation: `docs/Architecture.md`, `docs/Backends.md`, expanded + `docs/Templates.md` and `docs/Risk_Consideration.md`, and this changelog. +- Extensive test coverage: unit tests for argument parsing, config loading, path + resolution, the HTTP helper, template rendering, the JSON parser, logging, the + syntax-highlight and paginator plugins; and end-to-end tests for the request + pipeline, hook stages, interactive mode, output redirection, error paths, the + launcher, record mode, the bash version gate and terminal-size fallback. + +## [0.1.1] + +- Baseline release prior to the hardening pass above. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..dfe353c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,125 @@ +# Contributing + +Contributions are welcome — issues, suggestions, and pull requests. This guide +covers the coding conventions and the test suite. For how the code is +organised, see [docs/Architecture.md](docs/Architecture.md). + +## Coding conventions + +ell is deliberately dependency-light bash. A few conventions keep it portable, +fast, and consistent; please follow them: + +- **Portability: bash >= 4.1, GNU *and* BSD tools.** The code runs on Linux and + macOS and under `set -o posix` in the test harness. Avoid bashisms that break + on 4.1 (e.g. process substitution `< <(...)` under `set -o posix` — use a + here-string instead), and prefer POSIX-portable tool invocations (`stat` + already falls back from GNU to BSD form). +- **No per-line subprocesses on hot paths.** Streaming loops and logging must + not fork `grep`/`cut`/`tr`/`sed`/`date`/`basename` per line/chunk; use bash + builtins (parameter expansion, `[[ ]]`, `printf -v`, `printf '%(...)T'`). +- **Prefer builtins over external tools** generally, and keep the runtime + dependency surface small (bash, curl, awk). +- **Quoting and comparisons.** Quote expansions. Use the portable + `[ "x${VAR}" = "xVALUE" ]` idiom for string comparisons (the `x` prefix guards + against values that look like operators); reserve `[[ ]]` for where it is + actually needed (regex `=~`, glob `==`). Use `[ "${n}" -ge N ]` for numbers. +- **Style.** Trailing semicolons on statements, as in the existing code. Keep + functions small; give private helpers an `_ell_`/`_json_`-style prefix and + export public functions with `export -f`. +- **Security first.** Never `eval` user-influenced data. Treat templates, + plugins and config as the trust-sensitive surfaces they are (see + [docs/Risk_Consideration.md](docs/Risk_Consideration.md)). Keep secrets out of + argv and logs. +- **Run ShellCheck.** `error`-level findings block CI; keep the diff clean of + new warnings too. + +Every behaviour-changing PR should come with tests, and the full suite must +pass under both supported bash versions (below). + +## Testing + +The tests are self-checking Bash scripts: each asserts expected values and +exits non-zero on any failure, so they can gate CI without human inspection. +They use a tiny built-in assertion helper (`tests/assert.sh`) rather than an +external framework, keeping the project dependency-free. The LLM backends are +exercised offline via the `ell_echo` dummy backend and `file://` JSON fixtures, +so no network or API key is required. + +### Layout + +Tests come in two flavours: + +- **Unit tests live next to the source they cover**, named `.test.sh`, + so a file's tests are easy to find right beside it: + + ``` + helpers/json.sh helpers/json.test.sh + helpers/logging.sh helpers/logging.test.sh + helpers/piping.sh helpers/piping.test.sh + helpers/render_to_text.awk helpers/render_to_text.test.sh + plugins/redaction/50_post_input.sh plugins/redaction/50_post_input.test.sh + ``` + +- **End-to-end tests that drive the whole `ell` pipeline** (and their JSON + fixtures) live in `tests/`, alongside the shared assertion helper + `tests/assert.sh`. + +### Running + +Run the whole suite once, on your host: + +```bash +bash tests/entry.sh +``` + +Or run it against the oldest and current supported Bash versions in Docker: + +```bash +bash tests/docker.sh +``` + +`tests/entry.sh` auto-discovers every `*.test.sh` in the repository and then +runs the end-to-end tests. `docker.sh` runs it inside `bash:4.1` and `bash:5.2` +containers and fails if the suite fails under either version. + +### Continuous integration + +`.github/workflows/ci.yml` runs on every push and pull request: + +- **ShellCheck** over all shell scripts, run at `-S warning`. Both `error` and + `warning` findings block the build: the repo is clean at warning severity, so + the gate ratchets the warning count at zero to prevent warnings from silently + accumulating. Unavoidable findings are silenced locally with a justified + `# shellcheck disable=SCxxxx` directive rather than by lowering the gate. +- **Tests** across a `bash:4.1` and `bash:5.2` container matrix. +- **Tests (mawk)** with mawk as the default `awk`, exercising the portable + `render_to_text.awk`. +- **Tests (macOS / BSD tools)** on `macos-latest`, exercising the BSD toolchain + (BSD awk/sed/stat/mktemp, macOS curl). +- **Tests (Windows / Git Bash, informational)** on `windows-latest`, which is + `continue-on-error` and never blocks the build; it reports what works under + Git Bash rather than gating on it. + +### Adding a test + +For a unit test, create `.test.sh` next to the file it covers, source +the assertion helper (via a path relative to that location), write assertions, +and end with `assert_summary`. It is picked up automatically by `entry.sh` — +no registration needed: + +```bash +#!/usr/bin/env bash +set -o posix; +DIR="$(dirname "${0}")"; +# From helpers/ this is ../tests/assert.sh; adjust the depth for other dirs. +. "${DIR}/../tests/assert.sh"; +. "${DIR}/my_helper.sh"; + +assert_equals "adds up" "3" "$((1 + 2))"; + +assert_summary; +``` + +End-to-end tests that need JSON fixtures or the full pipeline go in `tests/` +(sourcing `"${DIR}/assert.sh"`) and are registered with an explicit +`run_test tests/.sh` line in `tests/entry.sh`. diff --git a/README.md b/README.md index 463c3cd..bc0ca21 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,25 @@ unless Developer Mode or administrator rights are available. No extra setup is required; clone the repository and add its directory to your `PATH` as shown above. You invoke it the same way, e.g. `ell "your prompt"`. +**Environment differences and limitations.** The Bash environments differ in how +faithfully they emulate a POSIX system, and a few features degrade accordingly: + +- **WSL** behaves like Linux; everything works. +- **MSYS2** and **Cygwin** provide a fairly complete POSIX layer (ACL-backed + file permissions, real symbolic links, `script(1)`), so the whole feature set + works. +- **Git Bash** is intentionally minimal. Over NTFS it cannot create genuinely + group/world-writable files, so the config-file permission check in + `load_config` (which refuses to source a world-writable `.ellrc`) and the + `chmod 600` on the temporary auth-header file **cannot be enforced**; it also + lacks `script(1)` (record mode) and, by default, real symlinks. ell still + runs, but on a multi-user machine treat these as **security limitations** — + see [Configuration → Windows](docs/Configuration.md#windows) for details and + the safer alternatives (MSYS2 / WSL). + +For the most complete experience on Windows, prefer **WSL** or **MSYS2** over +Git Bash. + ## Configuration See [Configuration](docs/Configuration.md). @@ -165,6 +184,20 @@ See [Plugins](docs/Plugins.md). The term "Plugin" here means a script that can be called by ell. It can be used to extend ell's functionality. The plugins supported by LLM providers is not included here. Please refer to [Templates](docs/Templates.md). +## Backends + +See [Backends](docs/Backends.md). + +A backend adapts ell to an LLM API "style" (selected with `--api-style` / +`ELL_API_STYLE`). OpenAI and Gemini are supported out of the box, and you can +add your own. + +## Architecture + +See [Architecture](docs/Architecture.md) for how ell is put together: the +startup sequence, configuration precedence, the request pipeline and its four +hook stages, backends, and record mode. + ## Risks to consider See [Risks Consideration](docs/Risk_Consideration.md). @@ -199,86 +232,21 @@ See [Risks Consideration](docs/Risk_Consideration.md). ## Testing -The tests are self-checking Bash scripts: each asserts expected values and -exits non-zero on any failure, so they can gate CI without human inspection. -They use a tiny built-in assertion helper (`tests/assert.sh`) rather than an -external framework, keeping the project dependency-free. The LLM backends are -exercised offline via the `ell_echo` dummy backend and `file://` JSON fixtures, -so no network or API key is required. - -### Layout - -Tests come in two flavours: - -- **Unit tests live next to the source they cover**, named `.test.sh`, - so a file's tests are easy to find right beside it: - - ``` - helpers/json.sh helpers/json.test.sh - helpers/logging.sh helpers/logging.test.sh - helpers/piping.sh helpers/piping.test.sh - helpers/render_to_text.awk helpers/render_to_text.test.sh - plugins/redaction/50_post_input.sh plugins/redaction/50_post_input.test.sh - ``` - -- **End-to-end tests that drive the whole `ell` pipeline** (and their JSON - fixtures) live in `tests/`: `tests/templating.sh` and `tests/parse_output.sh`, - alongside the shared assertion helper `tests/assert.sh`. - -### Running - -Run the whole suite once, on your host: - -```bash -bash tests/entry.sh -``` - -Or run it against the oldest and current supported Bash versions in Docker: - -```bash -bash tests/docker.sh -``` - -`tests/entry.sh` auto-discovers every `*.test.sh` in the repository and then -runs the end-to-end tests. `docker.sh` runs it inside `bash:4.1` and `bash:5.2` -containers and fails if the suite fails under either version. - -### Continuous integration - -`.github/workflows/ci.yml` runs on every push and pull request: - -- **ShellCheck** over all shell scripts. Findings at `error` severity block the - build; warnings are reported but non-blocking so they can be cleaned up - incrementally. -- **Tests** across a `bash:4.1` and `bash:5.2` matrix. +Run the whole suite on your host with `bash tests/entry.sh`, or across the +supported Bash versions in Docker with `bash tests/docker.sh`. The tests are +self-checking and dependency-free (the LLM backends are exercised offline via +the `ell_echo` backend and `file://` fixtures, so no network or API key is +needed). See [CONTRIBUTING.md](CONTRIBUTING.md) for the layout and how to add a +test. -### Adding a test - -For a unit test, create `.test.sh` next to the file it covers, source -the assertion helper (via a path relative to that location), write assertions, -and end with `assert_summary`. It is picked up automatically by `entry.sh` — -no registration needed: - -```bash -#!/usr/bin/env bash -set -o posix; -DIR="$(dirname "${0}")"; -# From helpers/ this is ../tests/assert.sh; adjust the depth for other dirs. -. "${DIR}/../tests/assert.sh"; -. "${DIR}/my_helper.sh"; - -assert_equals "adds up" "3" "$((1 + 2))"; - -assert_summary; -``` +## Contributing -End-to-end tests that need JSON fixtures or the full pipeline go in `tests/` -(sourcing `"${DIR}/assert.sh"`) and are registered with an explicit -`run_test tests/.sh` line in `tests/entry.sh`. +Contributions are welcome! Please open an issue or submit a pull request. See +[CONTRIBUTING.md](CONTRIBUTING.md) for coding conventions and the test suite. -## Contributing +## Changelog -Contributions are welcome! If you have any ideas, suggestions, or bug reports, please open an issue or submit a pull request. +See [CHANGELOG.md](CHANGELOG.md) for notable changes. ## License diff --git a/docs/Architecture.md b/docs/Architecture.md new file mode 100644 index 0000000..1d2b373 --- /dev/null +++ b/docs/Architecture.md @@ -0,0 +1,129 @@ +# Architecture + +ell is a small, dependency-light bash program: an entrypoint (`ell.sh`), a set +of sourced helpers under `helpers/`, pluggable LLM backends under +`llm_backends/`, and user plugins discovered across several search roots. This +document explains how the pieces fit together. + +## Entry points + +- **`ell`** — a thin launcher. It resolves its own directory (following + symlinks) and execs `ell.sh`, so the bundled helpers/templates are found no + matter how ell was invoked (in place, on `PATH`, or via a symlink in + `~/.local/bin`). +- **`ell.sh`** — the orchestrator. It checks the bash version, sources the + helpers and the selected backend, loads configuration, parses arguments, + builds a request from a template, runs it through the backend, and applies + plugin hooks around each stage. + +## Startup sequence (`ell.sh`) + +1. **bash version gate** — require bash >= 4.1. +2. **Resolve `BASE_DIR`** to an absolute path (needed because record mode + re-execs ell from a different working directory). +3. **Source helpers** (`helpers/*.sh`) and then the backend dispatcher. +4. **`load_config`** — layer configuration (see below). +5. **Apply defaults** for any `ELL_*` still unset. +6. **`parse_arguments`** — command-line flags override everything. +7. **Preflight** — for a network backend, require `ELL_API_URL` (fatal if + missing) and warn if `ELL_API_KEY` is unset. +8. **Decide output** — redirect stdout to `ELL_OUTPUT_FILE` (plain mode only), + detect TTY, read terminal size. +9. **Decorate `generate_completion`** with the pre/post-LLM hooks. +10. **Record mode** (optional) — re-exec ell under `script` to capture the + session (see below). +11. **Resolve the template**, optionally read the prompt from a file. +12. **Build and send the request**, once (one-shot) or in a loop (interactive). + +## Configuration precedence + +`load_config` layers configuration so that later sources win, while never +overriding a variable already set in the environment: + +1. built-in defaults (in `ell.sh`) +2. config files, in order: XDG config, `~/.ellrc`, `$PWD/.ellrc`, `$ELL_CONFIG` +3. environment variables +4. command-line arguments (highest priority) + +Config files are *sourced* (executed), so `load_config` only sources a file +owned by the current user (or root) and not group-/world-writable. See +[Configuration](Configuration.md) and [Risk Consideration](Risk_Consideration.md). + +## The request pipeline and hook stages + +A prompt flows through four hook stages. Each stage runs every plugin hook of +that kind (discovered across the search roots) as a pipeline via `piping`: + +```mermaid +flowchart TD + P["USER_PROMPT"] + RT["render_template
(build JSON payload)"] + B["backend
generate_completion (LLM)"] + OUT["stdout"] + + P -->|"post_input hook
(redaction)"| RT + RT -->|"pre_llm hook
(transform payload)"| B + B -->|"post_llm hook
(transform response)"| M(["response text"]) + M -->|"pre_output hook
(paginate, highlight)"| OUT +``` + +- **`post_input`** — transforms `USER_PROMPT` (and the recorded + `SHELL_CONTEXT`) *before* it is substituted into the template. This is where + redaction runs, so secrets are scrubbed before anything is sent. +- **`render_template`** — substitutes the allowlisted `${VAR}` placeholders into + the JSON template, JSON-escaping string values. Templates are treated as data + and never shell-evaluated. See [Templates](Templates.md). +- **`pre_llm`** — transforms the request payload before it reaches the backend. +- **backend** — `generate_completion` sends the payload and prints the + completion text. See [Backends](Backends.md). +- **`post_llm`** — transforms the backend's response. +- **`pre_output`** — transforms the final text before it is written to stdout + (pagination, syntax highlighting). + +The decoration in `ell.sh` renames the backend's `generate_completion` to +`orig_generate_completion` and wraps it so that `pre_llm | orig_generate_completion +| post_llm` runs as one pipeline, with the backend's exit status (not the last +hook's) propagated. + +Hooks are shell scripts named `_.sh` (e.g. `50_post_input.sh`) under +`plugins//` in any search root; a hook whose filename contains `.disabled` +is skipped. See [Plugins](Plugins.md). + +## Backends + +`ELL_API_STYLE` selects `llm_backends/