# 0041: Ruby scripts run like shell scripts

> A Ruby script is checked and run the same way as a shell script, as the person, through the helper gate.

Source: https://wolos.org/reference/adr-0041

## Context

WolOS treats a shell script as a first-class program: it has a header rule, a lint pipeline (docs/RULES-system.md, `sh -n`, shellcheck), CI steps, exec-bit checks, and when it calls `wol` with `WOL_HELPER=<name>` it goes through the helper gate (policy, approval sheet, audit log; docs/modules/automation.md). Nothing in the product ran a script on request: there was no `wol` verb for a script file, and remote runs existed only in developer tooling. Ruby had none of this: `tools/check-headers.sh` refused a ruby shebang, the rule tests and CI ignored `.rb`, and no Ruby interpreter was on the system. [ADR 0036](/reference/adr-0036) makes Crystal the language of apps and tools; Crystal compiles, so it is a poor fit for a file you edit and run, which is what a script is.

## Decision

- **Ruby is the scripting language beside sh.** Crystal stays the language of apps and tools ([ADR 0036](/reference/adr-0036)); Lua stays the plugin sandbox ([ADR 0031](/reference/adr-0031)). A `.rb` file is a script wherever a `.sh` file is: a bundle entry, a widget source, a plugin or hotkey command, a helper with `WOL_HELPER`, a file run over ssh. Ruby calls WolOS through the `wol` CLI with `--json`, as sh does; there is no Ruby library over libwol ([ADR 0036](/reference/adr-0036), lock-in).
- **One runner for every script language: `wol script`** (`crates/controller/src/script.rs`). `wol script check <file>` parses without running (`sh -n`, `bash -n`, `ruby -wc`), then runs the language's linter when it is installed (shellcheck; RuboCop with the explicit cop list `RUBOCOP_COPS`). `wol script run <file> [args]` runs it as the person, never as root, with an argv (never a shell string), a clean environment (`env -i` keeping only `KEPT_ENV`: who, where, language, terminal and the desktop session; a fixed `PATH`; no tokens or keys from the calling shell) and `WOL_HELPER=<file name>`, so every change the script asks of `wol` meets the gate. `wol script on <host> <file> [args]` sends the file on ssh's stdin (`BatchMode`, no terminal) and runs it on the other computer the same way: same clean environment, same helper name, so the other WolOS applies its own gate. Each run is one audit record (`script run` or `script on`, the exit code; the script's arguments are not recorded). What differs between languages is one table, `KINDS` (kind by shebang, else by file name ending). Helpers may list the kinds and check a file (Read); `run` and `on` execute code and stay for people (not in `HELPER_VERBS`, not offered by `wol mcp`).
- **What "sandboxed" means in this release, stated plainly:** the clean environment, no root, no shell parsing, the helper gate for every `wol` change, and the audit record. It is not a file-system jail: the script can read and write what the person can. The workspace jail (`wol jail run`, [ADR 0032](/reference/adr-0032)) is the planned isolation and will wrap `wol script run` when it lands; until then a script is as trusted as the person running it, like a shell script.
- **The interpreter ships in the image; the linter in the developer module.** `ruby` from the base distribution's repositories at the release snapshot ([ADR 0016](/reference/adr-0016)) in the desktop package list, so a fresh computer and an ssh session can run a `.rb` file; `rubocop` in the `dev` module (`files/usr/share/wolos/modules/dev.json`). Not from mise: a system script needs the pinned package, not a user-side build.
- **No Ruby root helpers.** Scripts under `files/usr/lib/wolos`, units and hooks stay POSIX sh (STANDARDS §2). RB-030 keeps `ROOT_RUBY` empty; adding one needs a new ADR and `ruby` in wolos-system's depends.
- **Rules:** docs/RULES-ruby.md (`RB-nnn`, `crates/controller/tests/rules_ruby.rs`): header, frozen string literals, argv-only commands, no eval, no object-building decoders, default gems only (`STDLIB`), timeouts, no fixed `/tmp`, no world-writable modes, `ruby -wc` clean, RuboCop with one explicit list (`tools/ruby/rubocop.yml`, `DisabledByDefault`, `NewCops: disable`), an interpreter census per folder. CI installs ruby and rubocop and sets `WOLOS_REQUIRE_RUBOCOP=1`, so a missing tool fails instead of skipping. `tools/check-headers.sh` accepts a ruby shebang and parses the file with `ruby -wc`.
- **Malware check:** the background pass looks at `.rb` files as it looks at `.sh` files (`xguard::sweep::in_scope`). The first-use check scans the interpreter once, not each script it reads; that gap is the same for sh and Ruby (security.md, interpreters).

## Consequences

the image grows by the interpreter (about 17 MiB installed for `ruby` 3.4 plus its `rubygems` dependency at the time of writing; the measured number belongs in the release notes) and gains a second scripting interpreter that the first-use check does not see into. `wol script` is the one place to add a language: PowerShell (planned) is one more row in `KINDS`. Ruby's default gems move between releases; RB-009 names the allowed list, so a snapshot bump that drops one fails loudly. RuboCop releases add cops; the explicit list keeps CI stable and RB-020 fails when a listed cop disappears. Phase 2 (not this ADR): the jail around `wol script run`, and completions for `wol` in the shells (there are none for any verb yet).
