Skip to content

Architecture

LianYaoHu launches a code agent (Claude Code, Codex, …) with a sanitized environment and a helper-managed network guard that forces traffic through a user-selected VPN interface. macOS uses sandbox-exec plus PF on utun*; Linux uses Landlock/seccomp plus owner-scoped iptables/ip6tables chains on tun* or wg*. This document describes how the pieces fit together; the policy rationale lives in security-model.md, and the end-to-end validation setup in e2e-testing.md.

Crates

text
crates/
├── lianyaohu-app      single binary crate, producing the `lianyaohu`
│                      executable. It contains the user-facing CLI launcher and
│                      the `lianyaohu helper` root daemon subcommand that
│                      applies firewall rules and launches dedicated-group
│                      sessions
└── lianyaohu-core     shared library: policy generation + system probes

lianyaohu-core

Everything policy-related is generated in one place and shared by the launcher and the helper, so both sides always render identical rules:

ModuleResponsibility
interfacesEnumerate interfaces via getifaddrs, collect IPv4/IPv6 and point-to-point peer addresses; supported VPN interfaces are utun* on macOS and tun*/wg* on Linux.
routeQuery the routing table for where all unbound IPv4 traffic leaves: the unscoped default routes (netstat -rn -f inet on macOS, ip -4 route show default on Linux) plus the kernel's pick for each half of the address space (route -n get / ip route get for 0.0.0.1 and 128.0.0.1).
policyTyped sandbox policy shared by launcher and helper: SandboxPolicy (network + path customization), the DestRule grammar (ADDR[/PREFIX][:PORT[-PORT]], parsed into IpAddr/prefix/port — never free text), the blocked-LAN constants, list caps, and lexical path validation.
configLayered TOML configuration: ConfigFile schema (deny_unknown_fields throughout), XDG global path + upward .lianyaohu.toml discovery, merge with per-key provenance, widening detection/stripping, and the hash-pinned trust store (trusted.toml).
sandbox_profilemacOS: render the sandbox-exec SBPL profile (deny-default; writable access to $HOME, $PWD, and a per-launch tmpdir; deny raw/system sockets, socket ioctls, inbound, bind, broad sysctl; allow loopback-only bind/inbound, outbound TCP/UDP, and the mDNSResponder socket). Applies the path policy: extra writable/read-only subpaths, narrow-home mode, and user deny rules appended last (seatbelt is last-match-wins).
linux_sandboxLinux: build and apply the child sandbox (PR_SET_NO_NEW_PRIVS, Landlock filesystem rules, and seccomp-BPF syscall filtering). Applies the path policy (extras, narrow-home); deny paths are reported as unenforced. Launch fails if Landlock is unavailable.
env_policySanitize the child environment: allowlist of operational variables and agent credentials (ANTHROPIC_*, OPENAI_*, GIT_*, …), blocklist of host-identity surfaces (SSH_*, XPC_*, hostname/MAC/serial/timezone markers); forces TZ=UTC and sets LIANYAOHU_SANDBOX=1.
launchSerialize the helper launch spec: argv, cwd, sanitized environment, the rendered sandbox profile/summary field, and (spec v2) the typed SandboxPolicy. A helper older than the spec's version refuses to run it.
pfmacOS: PFRuleSet renders the PF anchor rules for a (utun, socket owner, network policy) tuple; the default helper path matches the _lianyaohu group, while the fallback path matches the caller UID. PFGuard installs fallback rules via the helper or sudo and uninstalls on Drop.
linux_firewallLinux: LinuxFirewallRuleSet renders and installs iptables/ip6tables OUTPUT chains for a (tun/wg, socket owner, network policy) tuple; the default helper path matches _lianyaohu, while the fallback path matches the caller UID.
helperClient for the helper daemon's protocol over /var/run/lianyaohu-helper.sock; the default run <utun> <spec> request passes stdio FDs with SCM_RIGHTS, capabilities probes policy support for version negotiation, and install <utun> [policy], uninstall, status remain for the current-UID fallback (a non-default network policy travels with the install request and is negotiated the same way).

lianyaohu-app (launcher)

run() is a straight pipeline; every step must pass before the agent starts:

  1. Parse options (--vpn, --cwd, --env, --no-pf, --shared-user-pf, --allow-non-default-route, policy flags, print/inspect modes), then load and merge the layered configuration (global file, trust-checked project file, CLI overrides) and build the typed SandboxPolicy from the result.
  2. Select the VPN interface — from --vpn, the configured default, or the interactive picker (a ratatui inline quick-pick with a live detail pane; a numbered stdin prompt for non-TTY runs and --no-tui) — and validate it for the current platform. The full-screen lyh config editor manages the persistent configuration.
  3. Sanitize the environment, then build the platform sandbox from $HOME, the canonicalized working directory, and a fresh per-launch tmpdir.
  4. Build the default group-scoped firewall rules for helper launches, or the current-UID rules when --shared-user-firewall is selected. macOS includes the utun's point-to-point IPv4 peer (if any) as the PF route-to gateway.
  5. Route preflight: refuse to launch unless the selected VPN interface carries all IPv4 egress — the kernel's route for both 0.0.0.0/1 and 128.0.0.0/1 must resolve to it, which covers a plain default route through the tunnel and the split-default ("def1") layout alike, and cannot be satisfied by a host route for a single address (unless --allow-non-default-route).
  6. Sanitize the environment (env_policy::sanitize).
  7. By default, write a launch spec and ask the helper to install group-scoped firewall rules, drop to uid=caller_uid,gid=_lianyaohu with the caller's normal supplementary groups, and launch the agent. macOS execs launchctl asuser (joining the caller's security session so the login keychain stays reachable) → lianyaohu drop-exec (credential drop) → /usr/bin/sandbox-exec; Linux applies PR_SET_NO_NEW_PRIVS, Landlock, and seccomp in the child before execing the requested command.
  8. With --shared-user-firewall, install the current-UID firewall guard and launch directly from the CLI.
  9. On agent exit, uninstall the firewall guard.

lianyaohu helper (root daemon)

The same binary run with the helper subcommand as a minimal root LaunchDaemon on macOS or systemd service on Linux (io.github.madeye.lianyaohu.helper, installed by scripts/install-helper.sh) that owns the privileged half of firewall enforcement:

  • Listens on /var/run/lianyaohu-helper.sock (mode 0666; authentication is per-request, not per-connection).
  • Authenticates each request with kernel peer credentials (getpeereid on macOS, SO_PEERCRED on Linux) — peer uid/gid are taken from the kernel, never from the request payload.
  • Creates or validates the hidden _lianyaohu group with fixed gid 2000000.
  • Accepts run <interface> <spec_path> for the default path. The client sends stdin/stdout/stderr FDs with the request; the helper reads the launch spec, installs firewall rules matching the _lianyaohu group in the caller's anchor/chain, and runs the command as the caller UID with _lianyaohu as the effective GID and the caller's normal supplementary groups.
  • Also accepts install <interface> [policy], uninstall, and status for the --shared-user-firewall fallback. In that path generated rules are scoped to the peer UID; a non-default network policy travels as typed JSON in the install request and is re-validated helper-side before it is applied.
  • Answers capabilities with its supported spec features (policy=1 install_policy=1 spec_version=2). Clients probe this before sending a non-default sandbox policy; an old helper answers the unknown verb with an error line, which is the client's signal to hard-error instead of running with a silently narrower policy. The install path probes install_policy the same way and falls back to sudo pfctl — which renders the full rule set — rather than letting an old helper install weaker default rules. The helper re-validates every policy field it receives — ownership and canonicalization for extra writable paths, a system-prefix denylist, other-users'-home rejection for read-only extras, LAN-exception containment, and list caps — before rebuilding the profile and rules.
  • The interface name must be supported for the platform and must be live and addressed at install time.
  • On macOS, writes rules to /var/run/lianyaohu/rules-<uid>-<utun>.pf (mode 0600), vets them with pfctl -n, enables PF with pfctl -E (tracking the enable token per uid), and loads the anchor.
  • On Linux, creates LYH-<uid> (group-scoped) or LYH-U-<uid> (user-scoped) iptables/ip6tables chains and inserts an OUTPUT owner jump matching either gid 2000000 or the caller uid. The scopes use distinct chain names so a user-scoped install can never flush a live group session's chain.
  • uninstall flushes the caller's anchor/chain and releases tracked state. SIGINT/SIGTERM unlink the socket on the way out.

PF anchor

Rules load into com.apple/lianyaohu-<uid> (group-scoped) or com.apple/lianyaohu-user-<uid> (the user-scoped fallback; distinct so a fallback install can never replace a live group session's rules), which macOS' default /etc/pf.conf evaluates through its anchor "com.apple/*" point — no edits to system PF configuration. For caller uid U, socket owner O, and interface utunN the generated policy is, in order:

  1. pass loopback TCP/UDP for owner O.
  2. block return TCP/UDP from owner O to RFC1918/link-local/CGN/multicast IPv4 and to loopback/link-local/ULA/multicast IPv6 (LAN lockout).
  3. pass ... route-to (utunN <peer>) — owner O IPv4 TCP/UDP leaving any other interface is steered into the utun (only when the utun has a point-to-point peer).
  4. block return owner O TCP/UDP on any interface other than utunN.
  5. pass owner O TCP/UDP on utunN.

The default helper path uses group 2000000 for O, so other processes owned by the same desktop UID are untouched. --shared-user-firewall uses user U for the fallback path. System daemons, including mDNSResponder, are untouched in both paths.

Linux firewall chains

Linux rules use iptables -m owner and ip6tables -m owner from the OUTPUT hook. For caller uid U, socket owner O, and interface tun0/wg0, the generated chain policy (LYH-<uid>, or LYH-U-<uid> for the user-scoped fallback) is:

  1. Return immediately for loopback. This exempts stub-resolver DNS (127.0.0.53/127.0.0.1); the stub's upstream queries are not owner O and follow the system routing table (see the security model's DNS section).
  2. Reject LAN, carrier-grade NAT, link-local, multicast, and IPv6 unique-local/link-local/multicast destinations.
  3. Return for traffic already leaving the selected VPN interface.
  4. Reject everything else from owner O.

The default helper path matches --gid-owner 2000000, so other processes owned by the same desktop UID are untouched. --shared-user-firewall matches --uid-owner U and can affect other sockets opened by that user while active.

Privilege and fallback

Firewall changes require root; the launcher runs unprivileged. The default path requires an updated helper daemon because the helper must both install firewall rules and drop the child to the dedicated effective GID before the child opens sockets. The --shared-user-firewall fallback installs current-UID rules directly from the CLI through sudo. On macOS the fallback asks the helper first and then falls back to sudo pfctl if the helper is unavailable.

Enforcement layers

The same escape has to defeat several independent mechanisms:

LayerMechanismStops
Process sandboxmacOS: sandbox-exec SBPL profile; Linux: Landlock + seccomp-BPFraw/system sockets, socket ioctls or kernel APIs, bind/inbound (loopback listeners allowed on macOS), filesystem and sysctl probing
Packet filtermacOS: group-scoped PF anchor; Linux: group-scoped iptables/ip6tables chainsLAN egress, egress on any non-selected interface
Route preflightwhole-IPv4-space route check at launchstarting the agent while traffic would bypass the VPN
Environmentenv_policy::sanitizehost/session identity leaking into the agent process

Released under the MIT License.