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
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 probeslianyaohu-core
Everything policy-related is generated in one place and shared by the launcher and the helper, so both sides always render identical rules:
| Module | Responsibility |
|---|---|
interfaces | Enumerate interfaces via getifaddrs, collect IPv4/IPv6 and point-to-point peer addresses; supported VPN interfaces are utun* on macOS and tun*/wg* on Linux. |
route | Query 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). |
policy | Typed 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. |
config | Layered 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_profile | macOS: 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_sandbox | Linux: 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_policy | Sanitize 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. |
launch | Serialize 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. |
pf | macOS: 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_firewall | Linux: 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. |
helper | Client 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:
- 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 typedSandboxPolicyfrom the result. - 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-screenlyh configeditor manages the persistent configuration. - Sanitize the environment, then build the platform sandbox from
$HOME, the canonicalized working directory, and a fresh per-launch tmpdir. - Build the default group-scoped firewall rules for helper launches, or the current-UID rules when
--shared-user-firewallis selected. macOS includes the utun's point-to-point IPv4 peer (if any) as the PFroute-togateway. - Route preflight: refuse to launch unless the selected VPN interface carries all IPv4 egress — the kernel's route for both
0.0.0.0/1and128.0.0.0/1must 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). - Sanitize the environment (
env_policy::sanitize). - By default, write a launch spec and ask the helper to install group-scoped firewall rules, drop to
uid=caller_uid,gid=_lianyaohuwith the caller's normal supplementary groups, and launch the agent. macOS execslaunchctl asuser(joining the caller's security session so the login keychain stays reachable) →lianyaohu drop-exec(credential drop) →/usr/bin/sandbox-exec; Linux appliesPR_SET_NO_NEW_PRIVS, Landlock, and seccomp in the child before execing the requested command. - With
--shared-user-firewall, install the current-UID firewall guard and launch directly from the CLI. - 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(mode0666; authentication is per-request, not per-connection). - Authenticates each request with kernel peer credentials (
getpeereidon macOS,SO_PEERCREDon Linux) — peer uid/gid are taken from the kernel, never from the request payload. - Creates or validates the hidden
_lianyaohugroup with fixed gid2000000. - 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_lianyaohugroup in the caller's anchor/chain, and runs the command as the caller UID with_lianyaohuas the effective GID and the caller's normal supplementary groups. - Also accepts
install <interface> [policy],uninstall, andstatusfor the--shared-user-firewallfallback. 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
capabilitieswith 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 probesinstall_policythe same way and falls back tosudo 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(mode0600), vets them withpfctl -n, enables PF withpfctl -E(tracking the enable token per uid), and loads the anchor. - On Linux, creates
LYH-<uid>(group-scoped) orLYH-U-<uid>(user-scoped) iptables/ip6tables chains and inserts an OUTPUT owner jump matching either gid2000000or the caller uid. The scopes use distinct chain names so a user-scoped install can never flush a live group session's chain. uninstallflushes 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:
passloopback TCP/UDP for ownerO.block returnTCP/UDP from ownerOto RFC1918/link-local/CGN/multicast IPv4 and to loopback/link-local/ULA/multicast IPv6 (LAN lockout).pass ... route-to (utunN <peer>)— ownerOIPv4 TCP/UDP leaving any other interface is steered into the utun (only when the utun has a point-to-point peer).block returnownerOTCP/UDP on any interface other thanutunN.passownerOTCP/UDP onutunN.
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:
- Return immediately for loopback. This exempts stub-resolver DNS (
127.0.0.53/127.0.0.1); the stub's upstream queries are not ownerOand follow the system routing table (see the security model's DNS section). - Reject LAN, carrier-grade NAT, link-local, multicast, and IPv6 unique-local/link-local/multicast destinations.
- Return for traffic already leaving the selected VPN interface.
- 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:
| Layer | Mechanism | Stops |
|---|---|---|
| Process sandbox | macOS: sandbox-exec SBPL profile; Linux: Landlock + seccomp-BPF | raw/system sockets, socket ioctls or kernel APIs, bind/inbound (loopback listeners allowed on macOS), filesystem and sysctl probing |
| Packet filter | macOS: group-scoped PF anchor; Linux: group-scoped iptables/ip6tables chains | LAN egress, egress on any non-selected interface |
| Route preflight | whole-IPv4-space route check at launch | starting the agent while traffic would bypass the VPN |
| Environment | env_policy::sanitize | host/session identity leaking into the agent process |