Skip to content

Security: cyberducttape/wayexpand

SECURITY.md

Security policy

Reporting a vulnerability

Please report suspected security vulnerabilities privately, not through a public GitHub issue.

  • Preferred: open a private security advisory on GitHub ("Security" tab → "Report a vulnerability"). This reaches maintainers directly without disclosing the issue publicly.
  • Include the affected version/commit, the backend(s) involved, reproduction steps or a proof of concept, and the impact you believe it has.

We aim to acknowledge new reports within 5 business days and to provide a status update (triage result, expected timeline) within 14 days. If a report is confirmed, we will coordinate a disclosure timeline with the reporter and credit them in the release notes unless they prefer to stay anonymous. Confirmed security fixes ship as patch releases as soon as they are ready rather than on a fixed embargo schedule.

Do not report non-security bugs through the advisory process — use a regular GitHub issue for those.

Security model

WayExpand is intended to run as the unprivileged desktop user. It must not be run as root.

For a concise table of protected, partially protected, and out-of-scope threats, see THREAT_MODEL.md.

The engine supports InputEvent::FocusChanged { sensitive: true }. An input source that can identify password or other sensitive fields must emit that event. The engine then clears its rolling buffer and ignores text until the focus becomes non-sensitive again.

The input-method source begins every activation in this disabled state and only enables capture after receiving a non-sensitive content type. Password, hidden-text, sensitive-data, and unknown values are treated as sensitive.

The daemon never logs raw input, trigger names, or replacement contents. Normal expansion logs contain only trigger character counts, erase counts, and replacement byte counts. Diagnostic reports should still avoid including configuration contents or typed text.

Configuration reload/startup failures are also logged without parser detail; use wayexpand doctor when detailed configuration diagnostics are needed.

Configuration files must be regular files and must not be writable by group or other users. Every ancestor directory must be owned by the current user or root and must be non-group/world-writable unless it has sticky protection. Root-owned sticky directories such as /tmp are permitted because their deletion/rename policy protects entries. Sticky mode permits the standard /tmp permission pattern but does not waive the ancestor ownership requirement. Configuration files must be owned by the current user or root.

Symlinked configuration paths are resolved before ancestor validation and file opening. This prevents a link swap from redirecting a trusted configuration path into an untrusted directory.

Command expansions are opt-in executable user content. They invoke a named program directly, never through a shell, with no stdin, discarded stderr, bounded arguments, a maximum five-second runtime, and a 1 MiB UTF-8 stdout limit. Non-zero exits, timeouts, invalid output, and oversized output fail closed without emitting a replacement. A command can still have side effects as the desktop user, so command-enabled configuration must remain protected by the ownership and permission checks above.

Important: Command-backed expansions run under the systemd user service's security hardening constraints (see systemd/wayexpand.service). The daemon runs with ProtectHome=read-only, ProtectSystem=strict, memory write-execute protection, and other sandboxing. This means a command that works when typed manually may fail when invoked as a WayExpand expansion if it tries to:

  • Write to $HOME or system directories
  • Connect to the network (IPv4, IPv6)
  • Access D-Bus services that require home-directory state or system-wide resources
  • Access files outside /tmp or /run
  • Perform other operations restricted by the systemd unit

Host limitation: ProtectHome=, ProtectSystem=, PrivateTmp=, and ReadWritePaths= need a mount namespace, which a systemd user manager can only create when unprivileged user namespaces are allowed. Where they are restricted (for example Ubuntu with kernel.apparmor_restrict_unprivileged_userns=1), systemd skips these directives without an error, and only the process restrictions (no new privileges, seccomp filters such as RestrictAddressFamilies= and MemoryDenyWriteExecute=, and resource limits) apply. To check a running daemon, compare readlink /proc/<daemon-pid>/ns/mnt with readlink /proc/self/ns/mnt: identical values mean the filesystem restrictions are not in effect.

Effective sandbox inspection

Run wayexpand doctor --security --profile workstation to inspect the live daemon's effective namespace IDs, NoNewPrivs and seccomp state, address-family socket probes, read-only mount flags, private /tmp, proc restrictions, W^X behavior, and configuration permissions. At daemon and Action Broker startup, WayExpand launches a harmless short-lived descendant in each service's own cgroup, starts a new session, and verifies that the production supervisor kills and reaps it. The daemon result appears as command_containment_self_test; the broker result is included in Action Broker diagnostics. The doctor requires passed for each command-capable service. A failed or explicitly disabled probe fails the managed readiness check. Under the default required-cgroup policy, command execution also fails closed when containment setup is unavailable; process-group cleanup is used only when the operator explicitly sets WAYEXPAND_PROCESS_CGROUP=disabled. When named actions are configured it also checks the live Action Broker and its in-process probes. --json emits the same checks for fleet tooling (schema 7), including overall readiness and independent execution_sandbox_readiness, input_privacy_readiness, configuration_integrity_readiness, action_broker_availability_readiness, and target_integrity_readiness values (pass, degraded, incomplete, or fail), the selected profile, and which checks are required. A workstation or hardened profile reports missing sensitive-field detection as degraded; strict profile reports incomplete when required evidence cannot be observed and fail only for a verified failed control or missing required input privacy. Sandboxing and input privacy are independent results. Unit-file directives alone are not treated as evidence. Kernel policy details that cannot be safely inspected from an unprivileged process appear in unavailable_evidence; observed failed controls appear separately in verified_failures. Each check reports pass, fail, or unavailable; missing probe data makes a required profile incomplete, while an observed failed control is fail. The execution-sandbox gate also requires the running daemon's status schema to match the CLI's expected schema. A stale daemon fails this check; a daemon that omits the schema is reported as unavailable and cannot pass readiness. The target-integrity gate passes when automatic replacement is refused by default or the live route is certified and atomic. An enabled non-atomic compatibility override fails; an uncertified route that advertises atomic replacement remains incomplete. The gates object groups check names and reports required/applicable status independently. An unused Action Broker is not_required; broker sandbox controls, when it is required, remain part of execution_sandbox. The old sandbox_readiness field is retained as an alias of execution_sandbox_readiness. Individual gate results may also be not_required; overall readiness remains pass, degraded, incomplete, or fail.

Protection profiles are:

  • workstation: requires the live daemon, safe configuration permissions, process restrictions, blocked direct IP sockets, W^X protection, command containment when command expansions are configured, and broker availability when required. Missing additional protections are reported as degraded.
  • hardened: requires all currently probeable daemon protections and reports optional missing probes as degraded. The known unavailable kernel/systemd evidence is also degraded, so this profile does not currently yield a pass until an authorized verifier can attest those controls.
  • strict: requires the hardened checks and complete evidence. A verified required-control failure reports fail; unavailable evidence reports incomplete rather than implying the control is broken. Strict therefore remains a non-passing gate until an authorized attestation mechanism can verify all listed controls.

The default profile is workstation. A managed deployment must explicitly validate hardened after starting the daemon; a unit file by itself is not a deployment assurance result. For example:

wayexpand doctor --security --profile hardened --json

The command returns nonzero for degraded, incomplete, or failed readiness, so it can serve as an Ansible, Puppet, or image-provisioning gate. Run it from the desktop user's session, outside the daemon's sandbox, because the check compares the daemon's namespaces with the caller's.

The report also identifies the active capture source and its privacy capabilities. Evdev is opt-in and cannot identify password or other sensitive fields. Fleet policy that requires sensitive-field protection must set require_sensitive_focus = true with safe_mode = true; this refuses routes such as evdev that cannot provide that signal. doctor --security --profile strict independently rejects evdev for input privacy even if the sandbox checks pass.

The systemd unit is deliberately restrictive to limit the blast radius of a compromised or misconfigured command. Do not relax the restrictions in systemd/wayexpand.service or use a wrapper script as a way to bypass them.

The unit permits Unix domain sockets (RestrictAddressFamilies=AF_UNIX), which are required for portals, D-Bus, and systemd user sockets. However, most D-Bus services require access to the home directory or system-wide state, so they are still blocked by the filesystem isolation settings above. A command that works with D-Bus when run manually may fail under the daemon's constraints.

Named actions provide a supported policy-controlled path for selected SRE commands that require capabilities unavailable to the daemon sandbox. The integrated Action Broker is a separate same-user process and policy boundary; it receives an explicit action name and enforces its executable, arguments, environment, cwd, timeout, output limit, and optional audit result. The packaged service denies IP networking. An action may also opt into Landlock read/write path grants, enforced in the child process and refused when Landlock is unavailable. Device isolation is not currently provided. Landlock is not a boundary against malicious software running as the same UID; network access remains global service policy rather than a per-action grant. See docs/ACTION_BROKER_ARCHITECTURE.md.

GUI command previews: With a reachable systemd user manager, direct previews run in a transient user unit with the capture service's sandbox directives, a minimal environment, and the command timeout. Named actions run through the Action Broker. Without a systemd user manager, the GUI offers Run locally (advanced), which executes directly with the desktop user's privileges; organization safe mode blocks this direct preview when the service sandbox is unavailable. Direct previews are not included in the broker audit. These modes are shown in the editor. The sandbox is only as strong as the host allows, and it does not protect against malicious software running as the same user. For details, see docs/OPERATIONS.md.

The input-method source fails closed when it cannot safely pass through a non-text key, preserve shortcut modifiers, or determine a UTF-8-safe Backspace range from surrounding text. Unsupported non-text keys require the daemon's libei pass-through injector; if that injector is unavailable or fails, the source reports an error and reconnects rather than silently discarding keys. Malformed protocol state remains fatal rather than risking text corruption.

The evdev source (--source=evdev) trades away a real security property the other sources have: it reads keyboard events directly from the kernel (/dev/input/event*) rather than through a Wayland protocol, so it has no way to learn which application field has focus. It therefore never suspends matching in password or other sensitive fields the way the input-method source does. Only enable it where that tradeoff is acceptable.

Granting evdev access is a separate, explicit, root-requiring step (scripts/install-evdev-permissions.sh, --dry-run first), never run automatically by the user installers or base distro packages. WayExpand's udev templates target udev keyboard-class event nodes. The active-seat mode uses logind/uaccess ACLs and does not add the user to the broad input group. The legacy input-group mode also adds the invoking user to input; that group may grant broader raw input-event access depending on the distribution's default input-device policy. Run sudo scripts/install-evdev-permissions.sh --uninstall to reverse WayExpand's installed evdev policy and remove the invoking user from input when appropriate.

This input-group path is the current legacy/simple access model, not the long-term preferred architecture. A small device broker remains an investigation candidate; session, seat, hotplug, and distribution behavior must be tested before it can replace the current path. See docs/EVDEV_ACCESS_DESIGN.md for the investigation plan and acceptance criteria.

Evdev also has a correctness limitation independent of permissions: capture is non-exclusive, so the application can receive a terminating key before WayExpand can erase and replace the trigger. The current quiet-period and held-key mitigations are best-effort only; they do not make replacement atomic. Do not use evdev where a lossless replacement guarantee is required. The architectural boundary and the risks of an EVIOCGRAB proxy are documented in docs/adr/0001-evdev-best-effort-semantics.md.

Insertion guarantees

WayExpand reports replacement assurance in replacement_assurance in wayexpand status --json, in doctor, and prominently in the GUI and TUI. The older insertion_assurance field remains as a coarse compatibility value.

  • Target-text-verified routes (input-method-v2 and IBus) check the field's own surrounding text before erasing, and their edit is bound to that field by the protocol. This assurance does not claim that erase and insert are a single atomic transaction; IBus is not atomic.

  • Keystroke routes (evdev capture with libei or wlroots output) erase with Backspace without seeing the field's text. They never erase unless the user sets compatibility_mode (in the primary config, the user layer, or the organization layer; curated packs cannot set it):

    • tracked-focus erases only while the window tracker (KDE) reports the same exact window, checked immediately before the first keystroke;
    • untracked-focus also erases when no tracker can confirm focus, and is labeled as such everywhere the route is shown.

    Live status calls a tracker-confirmed operation focus-tracked-best-effort, because it cannot establish that the cursor or surrounding text still matches the trigger. An operation with no confirmed focus is unverified; with compatibility_mode = "off", the route is blocked and refuses before sending any erase or insertion keystroke.

    In every mode, a focus change detected between matching the trigger and typing refuses the expansion, as does a tracker that disconnects or stops.

  • Refusals happen before anything is typed: nothing is erased or inserted, the expansion is not retried, and it is counted in output_refused_total with its reason in output_last_refusal.

  • Uncertain writes are never retried. When a keystroke route fails or stops acknowledging partway through an expansion, the daemon counts it in output_uncertain_total, reports it in doctor and the GUI, and reconnects without replaying it.

Limits: focus tracking is only as current as the compositor's reports, so a focus change in the last moments before typing, or one the compositor has not yet reported, cannot be detected; and a keystroke route cannot detect a cursor moved by mouse within the same window. These are the reasons keystroke routes require explicit consent. Organizations can forbid them entirely with minimum_replacement_guarantee = "verified" in safe mode.

Security regression coverage

Targeted Rust regression tests exercise important process and storage boundaries:

Boundary Representative coverage
Executable trust pinned_shebang_launch_uses_the_validated_inode_after_path_replacement
Broker IPC bounded/invalid frame tests and ipc_concurrent_start_allows_only_one_owner
Sensitive fields input-method and IBus password/unknown-purpose tests, including an in-flight transition to a password field
Clipboard bounded command environment, single worker, deadline, and disconnected-worker tests
Environment forwarding broker tests prove an empty allowlist does not inherit client variables and client values cannot override server-owned values
Configuration saves parent ownership/mode and symlink tests; config_lock_rejects_hard_linked_lock_files; concurrent-writer tests
Process cancellation delegated cgroup v2 setsid() descendant cleanup, inherited-stdout, and cancellation tests in command runtime and broker
Audit logs private/single-link file, symlink-safe rotation, bounded required-audit backpressure, and rollback-failure tests

These tests establish code-level properties for their tested conditions. They do not certify sensitive-field behavior in a real compositor/client, validate every filesystem race on every supported filesystem, or replace desktop session testing. Cgroup containment requires the bundled systemd delegation and is not a privilege boundary against hostile same-UID commands. No compositor route is currently certified.

Backends must document their permission requirements explicitly:

  • direct libei/EIS requires an explicitly configured LIBEI_SOCKET; portal libei requires explicit backend selection and an approved desktop remote-desktop session;
  • wlroots virtual-keyboard is compositor-specific and requires the protocol to be available;
  • uinput backend is not currently implemented;
  • plugin execution must be disabled by default and sandboxed if added.

The control socket lives at $XDG_RUNTIME_DIR/wayexpand.sock (or the explicit WAYEXPAND_SOCKET path), is resolved against a validated parent, created under a restrictive umask, and finalized at mode 0600. Its immediate parent and all ancestors must be owned by the current user or root and must not be group/world-writable unless the directory has the sticky bit set; this includes the immediate socket parent, so a sticky directory such as /tmp is allowed. Stale-socket cleanup requires both the current UID and the original device/inode identity. The daemon refuses to remove non-socket or differently owned paths. Service units also restrict memory, tasks, file descriptors, and restart frequency. They additionally isolate temporary files, devices, mounts, kernel interfaces, process visibility, realtime scheduling, and syscall architecture through the shipped systemd user units.

The daemon also checks Linux SO_PEERCRED on each control connection and requires the peer UID to match the daemon UID. This authenticates the Unix user, not an individual application: all ordinary processes under one UID share that identity. The focused-window token is a freshness check, not an authorization secret. Organizations that must prevent local applications from invoking WayExpand through the socket can set [organization] safe_mode = true and disable_control_socket_mutations = true; this denies socket-based insert, pause/resume, reload, stop, and usage-stat clearing while leaving read-only status and focus requests available and reduces explain answers to a match/expand summary. It does not defend against malicious code with the same UID using other user-session privileges such as systemctl --user, editing user-owned configuration, or controlling the desktop session.

Sensitive data in process memory

The engine now uses zeroize for its live input history when characters are backspaced, evicted, reset, or cleared after a focus/context transition. It wipes its reusable Unicode matching scratch before reuse and shutdown, wipes undo text when the undo record is invalidated, and does not retain undo copies unless an undo chord is configured. Command-cache values are wiped when their cache entries are dropped. Owned InputEvent::Text input and prefetched clipboard values are wiped after processing or when consumed, replaced, expired, or shut down. Shipped systemd services set LimitCORE=0.

These are best-effort controls over specific engine-owned allocations. They do not erase immutable configuration snapshots, returned expansion results, in-flight command/template strings, all clipboard-reader copies, stack spills, registers, prior allocations left by reallocation, or data copied by the allocator or operating system. GUI processes launched outside the shipped systemd services do not inherit those units' core-dump limit. Do not treat zeroization as a guarantee that secrets never appear in a process dump or can never be recovered from memory.

Routine insert-refusal logs record only the fixed reason focus_changed; they do not include compositor window identifiers or focus tokens. Direct command stderr is drained to avoid blocking the child, then discarded. It is not retained in CommandError, its Display/Debug representation, or the daemon's failure trace. This favors confidentiality over exposing arbitrary helper diagnostics; use the helper's own authorized administrative channel when detailed diagnostics are required.

Command execution architecture limitations

The current command-backed expansion architecture prioritizes safety at the cost of restrictive resource constraints. It is not an Action Broker and is not an SRE command-execution boundary:

Current design:

  • Commands run directly in the daemon process, subject to systemd hardening
  • ProtectHome=read-only, ProtectSystem=strict, and direct IPv4/IPv6 socket creation blocked. AF_UNIX IPC remains available, so a local proxy service could relay requests; these settings do not establish total egress isolation.
  • Prevents legitimate SRE tools from working: kubectl, aws, vault, ssh, etc.

The IBus integration keeps direct program commands disabled because IBus runs outside the hardened daemon service. Named action commands are allowed only through the integrated Action Broker, where broker availability and policy are checked before execution. IBus therefore supports static expansions, direct program commands remain disabled, and managed actions use the broker boundary.

Enterprise/SRE use case conflict: A typical SRE might want:

[[expansion]]
trigger = ":shortlist"
replacement = ""  # typed text comes from the command
[expansion.command]
program = "kubectl"
args = ["get", "svc", "-o", "wide"]

This command fails in the current sandbox and cannot be fixed without materially weakening the daemon's security posture for all users.

Action Broker architecture (implemented for named actions)

The current implementation separates concerns:

Capture/Match/Injection Process (extremely locked down)
         │
         │ constrained IPC (command name + args only)
         ▼
Action Broker (per-user policy engine)
         └─ allowed executables whitelist
         └─ network access policy
         └─ filesystem access policy
         └─ environment variable allowlist
         └─ command timeouts and resource limits

Benefits:

  • Capture daemon stays extremely hardened, never touches the network or home dir
  • Action broker runs with exactly the permissions needed for each environment
  • Organization deploys safe_mode = true in config to completely disable command execution
  • Command policies are inspectable and auditable
  • Legitimate commands can be whitelisted by organization policy

This approach is particularly valuable for the target audience: SREs and system administrators who need to trust WayExpand in hardened environments. Configure the broker service and action catalog before using action = "..."; an unavailable broker or missing action fails closed rather than falling back to a direct command.

There aren't any published security advisories