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.4bump shipped the backend-identity gate and the two new opt-ins (below). A caveat when reading the fork clone: the fork's owndrush.infomay lag the control file — it readsdrush_version=8.5.3at HEAD while the installer already pins8.5.4, because the version-string sync commit lands separately from the behaviour commits. TrustBARRACUDA.sh.txtfor what a box installs, not the fork'sdrush.info. - The 10/11 pins are de-suffixed.
_DRUSH_TEN_VRN/_DRUSH_ELEVEN_VRNtrack the real upstream10.6.2/11.6.0rather than a fork-suffixed string, so the standalonedrush10/drush11binaries (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 likesites/all/modules/evil/evil.drush.incmatches no^/-anchored pattern and would load.command.inc:1640canonicalizes withPath::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 fromposix_geteuid()→ the root-owned/etc/passwddir, which a client cannot spoof downward.getenv('HOME')is deliberately not used: several BOA backend callssu <user>without-, leavingHOME=/rootwhile the euid is correctlyoN— a HOME lookup would derive the wrong user or none. TheDRUSH_BACKENDcontext is also rejected as an identity signal (it is unset at the earliest discovery site and is merely a--backendoption). - Octopus-user discriminator.
boa_drush_extension_octopus_user()(:102-120) resolvesoNonly when the passwddiris exactly/data/disk/<seg>andname === segand 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()andoctopus_user()memoize in astatic— 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:realpathdoesn'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:
- Global kill switch present → allow (filter off entirely).
- Not a backend identity → allow (limited client's own drush, no escalation possible).
- Path not on tenant-writable territory → allow.
- 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 undersu-without-dash (where HOME was/rootwhile euid wasoN). - 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
- Aegir backend APIs (topic chapter) — the task round-trip this Drush process drives, and the PHP 5.6 floor every commandfile must clear.
- Extending Aegir: contexts, hooks, module pairs — writing the backend commandfile whose discovery this filter governs.
- Provision backend internals — the Provision drush
extensions in the
.drush/{sys,usr,xts}/carve-out subtrees. - Provision API — the backend hook surface those commandfiles register against.
- Variables — the consolidated
_VARreference including the_DRUSH_*_VRNpins.