Drush fork internals (8-boa-micro)

The classic Drush 8 that runs every Aegir backend task is the omega8cc fork, not stock drush-ops/drush. It is a ~2015-era Drush 8 tree carrying two BOA-specific layers on top: a PHP 8.x survival backstop so a modern-PHP deprecation can't abort a task, and a default-deny *.drush.inc extension filter that closes the provision arbitrary-code path (drupal.org #762138) without breaking a limited client's own CLI. This page is the code view of both layers. The operator-facing behaviour of the filter (when a contributed command stops loading, how to opt in) lives in the operating guide and is not linked from here.

Stock Drush is not the authority for this tree — omega8cc is upstream for the fork. Everything below is verified against the fork's own source at HEAD.

The base and the version pins

BOA installs classic Drush 8 as the 8-boa-micro variant — a micro-patched Drush 8 branch fetched at install time, not vendored inside the BOA meta-installer. The point-release is pinned in the barracuda control file and mirrored to the octopus one:

Pin Value Source
_DRUSH_EIGHT_VRN 8.5.4 BARRACUDA.sh.txt:50
_DRUSH_EIGHT_TEST_VRN 8.5.4 BARRACUDA.sh.txt:51
_DRUSH_VERSION (octopus) 8.5.4 OCTOPUS.sh.txt:56
_DRUSH_TEN_VRN 10.6.2 BARRACUDA.sh.txt:49
_DRUSH_ELEVEN_VRN 11.6.0 BARRACUDA.sh.txt:48

_DRUSH_VERSION/_DRUSH_VERSION_TEST alias the Drush 8 pins at BARRACUDA.sh.txt:158-159, so the classic backend Drush across the whole stack is a single version knob.

Two mechanics worth knowing when reading the pins:

  • Classic 8.5.x bumps roll forward as the fork accretes PHP-compat and filter fixes. The 8.5.3 → 8.5.4 bump shipped the backend-identity gate and the two new opt-ins (below). A caveat when reading the fork clone: the fork's own drush.info may lag the control file — it reads drush_version=8.5.3 at HEAD while the installer already pins 8.5.4, because the version-string sync commit lands separately from the behaviour commits. Trust BARRACUDA.sh.txt for what a box installs, not the fork's drush.info.
  • The 10/11 pins are de-suffixed. _DRUSH_TEN_VRN/_DRUSH_ELEVEN_VRN track the real upstream 10.6.2/11.6.0 rather than a fork-suffixed string, so the standalone drush10/drush11 binaries (kept only for alias-translation) don't read as a forced upgrade against upstream.

PHP 8.x startup backstop

Classic Drush 8 predates every PHP 8 deprecation wave. Left alone, a deprecation raised anywhere during bootstrap gets caught by Drush's own shutdown handler and re-reported as a fatal "Drush command terminated abnormally due to an unrecoverable error" — a deprecation is not fatal, but error_get_last() sees it and mislabels it. Two edits make the fork survive PHP 5.6–8.5 in one binary.

Mask deprecations at the earliest point. drush.php masks E_DEPRECATED | E_USER_DEPRECATED out of error_reporting() before preflight.inc (and everything it includes) is compiled, so compile-time deprecations in those files are covered too:

// drush.php:32-33
if (defined('E_DEPRECATED')) {
  error_reporting(error_reporting() & ~E_DEPRECATED & ~E_USER_DEPRECATED);
}

The mask is not a blanket silencer: drush_errors_on() / drush_errors_off() snapshot and restore error_reporting through the DRUSH_ERROR_REPORTING context, so the mask propagates through their on/off cycles instead of being reset to E_ALL. Specific deprecations are still fixed at source where hit; this is only the backstop for ones not yet reached at runtime, and they stay visible by raising error_reporting manually when debugging. The Drupal-6-oriented E_DEPRECATED strip already in the fork's error handler (environment.inc:24-25) is a separate, older mask on the reporting side.

Shutdown reports only fatal-class errors. drush_shutdown() gates the "abnormal termination" cause on a fatal-class bitmask, so a lingering masked deprecation or notice (e.g. one emitted at autoload by an old vendored library like psysh) can never be reported as the unrecoverable error and hide the real cause:

// preflight.inc:785-786
$fatal = E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR;
if ($error['type'] & $fatal) {
  // ... only then build the "Error: ..." cause message
}

The two edits are complementary: the mask stops a deprecation from aborting the run; the shutdown gate stops a recorded-but-harmless one from being misattributed if the run aborts for some other reason.

The wider PHP 8.5 compat approach for this fork — implicitly-nullable params made explicit, type hints dropped rather than using ?nullable (so the code spans PHP 5.6–8.5), canonical (int) casts instead of (integer) — is design context for why the tree parses on both the 5.6 floor and 8.5, and belongs to the same span-the-range contract that governs the whole Aegir PHP layer (see the topic chapter).

The extension deny-filter — where it wires in

The filter lives in includes/boa_extension_filter.inc in the fork and is the single source of truth for three *.drush.inc discovery call sites. preflight.inc:129 require_onces it once, early, so its functions are defined before any discovery runs. The three sites:

Call site Line How it filters
_drush_add_commandfiles() scan command.inc:1641 if (!boa_drush_extension_allowed($canonical)) continue; on the canonicalized scan path
_drush_add_commandfiles() load command.inc:1666 re-check at load time so a stale commandfile cache can't bypass the deny set
annotated-command discovery (adapter) annotationcommand_adapter.inc:154 array_filter($files, 'boa_drush_extension_allowed', ARRAY_FILTER_USE_KEY)
annotated-command discovery (preflight) preflight.inc:553 same ARRAY_FILTER_USE_KEY filter

Two lessons are baked into how these sites call the filter:

  • ARRAY_FILTER_USE_KEY, not the value. In the annotated-command arrays the key is the path and the value is a class name. Both discovery sites were silent no-ops before the fix because they filtered the value — testing a class name against path patterns, which never matches. Filtering the key is the whole fix at those two sites.
  • Canonicalize before matching (the F1 no-op bug). The deny patterns are anchored #^/data/disk/...#, but Drush feeds the scanners paths that are relative to the Drupal root (drupal_get_path() / extension.list.module->getPath()) during site/full bootstrap. A relative path like sites/all/modules/evil/evil.drush.inc matches no ^/-anchored pattern and would load. command.inc:1640 canonicalizes with Path::canonicalize() before the check; the filter's own entry point canonicalizes again defensively (below). Resolving to an absolute, symlink-free path first is the load-bearing precondition for the whole filter — skip it and every pattern is a no-op.

Identity gate — who the filter applies to (F1b)

The #762138 threat is privilege escalation: tenant code auto-loaded and executed as the aegir master or an Octopus instance user during a backend task. A limited client running its own drush @site escalates to nothing — it already owns that uid — so its own contrib *.drush.inc must keep loading. The gate that draws this line is boa_drush_extension_backend_identity() (boa_extension_filter.inc:148-174):

posix_geteuid()  →  posix_getpwuid()  →  passwd 'dir'
   dir == /var/aegir      and name == aegir   →  BACKEND (filter ON)
   dir == /data/disk/<oN>  (name==segment,     →  BACKEND (filter ON)
                            no dot in name)
   otherwise (incl. <oN>.ftp, <oN>.<client>)  →  client  (filter OFF)

Design points, all in source:

  • Keyed on effective uid, not $HOME, not --backend. Identity is derived from posix_geteuid() → the root-owned /etc/passwd dir, which a client cannot spoof downward. getenv('HOME') is deliberately not used: several BOA backend calls su <user> without -, leaving HOME=/root while the euid is correctly oN — a HOME lookup would derive the wrong user or none. The DRUSH_BACKEND context is also rejected as an identity signal (it is unset at the earliest discovery site and is merely a --backend option).
  • Octopus-user discriminator. boa_drush_extension_octopus_user() (:102-120) resolves oN only when the passwd dir is exactly /data/disk/<seg> and name === seg and the name contains no dot. The name-equals-segment + no-dot tie keeps the dotted <oN>.ftp / <oN>.<client> limited users off the instance-user path even if a home were ever mis-set under /data/disk.
  • Fail-closed. Missing posix_geteuid/posix_getpwuid, or an effective uid of 0 (root), forces $is_backend = TRUE — the deny set stays on (:153-161). The only thing ever lost by failing closed is a limited client's convenience; the #762138 defence never is. posix is a hard dependency of the BOA CLI already (environment.inc), so the missing-extension branch is belt-and-suspenders.
  • Cached per process. Both backend_identity() and octopus_user() memoize in a static — the gate is evaluated once even though the filter is called per candidate file across all three discovery sites.

The deny set, carve-outs, and canonicalization

boa_drush_extension_path_denied() (:286-303) is the pure path predicate: match a deny pattern, then reject if a carve-out also matches.

Deny patterns (boa_drush_extension_deny_patterns(), :65-70) — tenant territory under an Octopus user's home:

#^/data/disk/[^/]+/(static|distro|platforms)(/|$)#i
#^/data/disk/[^/]+/\.drush(/|$)#i

Carve-outs (boa_drush_extension_deny_exceptions(), :79-83) — the BOA-managed subtrees that hold Provision and Aegir contrib drush extensions and must keep loading:

#^/data/disk/[^/]+/\.drush/(sys|usr|xts)(/|$)#i

The positional anchor is deliberate: [^/]+/(static|distro|platforms) denies the tenant distro/ while /data/disk/<u>/aegir/distro/ (Drush's own install, one segment deeper under aegir/) never matches, so Drush's own code loads. /var/aegir/* is not in the deny set at all — the aegir master hosts no tenant sites under BOA — and anything outside these roots is default-allow.

Canonicalization (boa_drush_extension_canonical_path(), :257-275) runs inside the entry point before the predicate:

  • realpath() resolves a relative path against the cwd (the Drupal root at bootstrap), collapses .., and follows symlinks — closing the relative-path, .., and symlink evasions in one call.
  • Stream wrappers pass through untouched. A path containing :// (phar://, vfs://, …) is returned as-is: realpath doesn't apply and Drush legitimately ships phar-packaged commandfiles, so they fall through to default-allow.
  • Fail toward DENY. If realpath() fails (e.g. a cached entry whose file is gone) but the path is relative, it is absolutised lexically against the cwd so a tenant-relative path still reaches the deny patterns — never a silent allow.

Opt-ins and the kill switch

The public entry point boa_drush_extension_allowed($path) (:324-336) is the whole decision, in order:

  1. Global kill switch present → allow (filter off entirely).
  2. Not a backend identity → allow (limited client's own drush, no escalation possible).
  3. Path not on tenant-writable territory → allow.
  4. Otherwise deny, unless the basename is opted-in per Octopus user.

The per-instance opt-ins and the kill switch are all presence-only, root-owned control files under /data/conf/ — content is irrelevant, existence is the signal, and each is cached in a static:

Control file Effect Function
/data/conf/<oN>_civicrm.txt backend oN may load civicrm.drush.inc, cv.drush.inc, civicrm_drush.drush.inc civicrm_enabled() :203-211; allowlist :48-54
/data/conf/<oN>_elysia_cron.txt backend oN may load elysia_cron.drush.inc (backend-mode cron via hosting_cron_use_backend keeps Elysia's scheduled granularity instead of degrading to core cron; a deliberate, explicit trust widening, off by default) elysia_enabled() :223-231
/data/conf/drush_extension_filter_disabled.txt filter OFF for every identity — single-tenant boxes only; re-opens #762138 filter_disabled() :185-192

boa_drush_extension_basename_allowed() (:349-359) is the only place a deny-matched path can still load: the CiviCRM opt-in gates the three CiviCRM basenames via strict in_array(..., TRUE); the elysia opt-in gates exactly elysia_cron.drush.inc. Everything else on a deny-matched path stays denied.

Two subtleties that follow from the identity gate:

  • The <oN> in both opt-in filenames is the euid-derived Octopus user, not a HOME-derived one — this is what fixed the opt-in miss under su-without-dash (where HOME was /root while euid was oN).
  • The opt-in check is reached only for backend identities — a limited client's own drush is already allowed unfiltered at step 2, before any /data/conf/ lookup.

Related