Reviewed: 2026-08-28
Wingthing has three independent security boundaries:
- The egg sandbox limits what an agent process can reach on the wing.
- The wing access policy decides which principal may operate a local resource.
- Transport encryption and signaling integrity protect direct WebRTC payloads; application-layer encryption protects payloads when an optional relay carries them.
Do not collapse those into one claim. E2E encryption does not constrain a same-user local process, and a local allowlist does not authenticate code served by a compromised web service.
Local CLI and MCP access use authenticated Unix sockets and operating-system file permissions. SSH attach uses OpenSSH's authentication, encryption, and host-key verification. Neither path uses Wingthing's relay encryption.
wt serve --local --https and wt roost start --https add an HTTPS browser
listener at https://localhost:8443. The flag is explicit because it performs a
local trust-store change:
- WT creates a private ECDSA P-256 CA key and localhost server key on demand in
~/.wingthing/local-tls. - The directory is mode
0700; both private keys are mode0600. - The CA is name-constrained to
localhost,127.0.0.0/8, and::1. - WT installs only the public CA certificate in the current user's trust store. Neither private key is passed to a trust command, copied to a wing, or sent over the network.
On macOS this is the user's login keychain and macOS displays its native
Certificate Trust Settings authorization dialog. On Windows it is the current
user's root store. On Linux WT uses the current user's Chromium NSS database;
certutil must be available from the distribution's libnss3-tools or
nss-tools package. These operations do not require a system-wide root store.
Use wt local-cert status to inspect the paths and verified trust marker. Use
wt local-cert remove to remove the public root from the current user's trust
store. Removal deliberately leaves the local key material intact so WT does not
silently replace a previously trusted authority.
HTTPS mode refuses wildcard, LAN, and public listener addresses. It uses two loopback listeners: port 8443 for the browser and HTTP port 8080 for embedded wings or wings arriving through an SSH reverse tunnel. That HTTP hop never leaves loopback; the SSH tunnel protects the host-to-host segment, and Wingthing's application encryption still protects browser-to-wing terminal payloads.
This device-local CA is not used by hosted, organization, or public shared-roost deployments. Those retain their existing externally provisioned HTTPS termination and OAuth behavior. Local HTTPS is opt-in. Without it, a single-user/no-login portal remains HTTP but its implicit listener is loopback-only and an explicit LAN or wildcard address is rejected; authenticated deployments retain their configured listener behavior.
Local mode also accepts only localhost/loopback Host headers. Browser WebSockets must be same-origin, and unsafe browser methods with an Origin header must match the exact HTTP or HTTPS origin. Requests marked cross-site by browser fetch metadata are rejected even if Origin is absent. Native wing and CLI calls do not send browser Origin metadata and remain available over the loopback transport. These are DNS-rebinding and browser-CSRF defenses; they do not make a no-login listener safe to expose beyond loopback.
For fast terminal restore and session-card thumbnails, the browser stores up to
200,000 serialized terminal characters and a WebP thumbnail per session in
plaintext origin localStorage. It also caches inventory, layout, and wing-key
pins there. This data does not go into the gateway database, but it is readable by
the browser profile, extensions with site access, and any script that can execute
in the portal origin. Clear the site's browser data to remove it. Local wing
history is a separate plaintext-at-rest store described below.
Agent-authored Markdown previews are rendered as network-inert text: raw HTML is
escaped, links are not active, image syntax does not fetch, scripts are disabled,
and the iframe has no normal origin. An agent-authored absolute HTTP(S) preview URL
is displayed without being requested. Every new URL requires the user to choose
load preview or open. Once chosen, the request originates from the browser
and is outside the egg's network allowlist; the destination sees the request and
network metadata. The embedded iframe uses allow-scripts without
allow-same-origin and a no-referrer policy so an agent-selected page or redirect
cannot become same-origin with the portal.
The free/default path uses the hosted service only for identity, the access-filtered wing directory, and encrypted WebRTC signaling:
native client ===== authenticated WebRTC/DTLS ===== wing
\------ wingthing.ai coordination only -----/
The WebRTC certificate fingerprints are inside the offer/answer exchanged
through the X25519-encrypted, TOFU-pinned signaling tunnel. MCP request and
result bytes then travel on the DataChannel, not through wingthing.ai.
Accounts with hosted relay access and self-hosted policies may use the browser relay path:
browser -- TLS --> wingthing.ai relay -- TLS --> wing
\_________ application ciphertext ________/
When entitled, the shipped relay forwards application ciphertext for terminal content and encrypted tunnel requests. During normal service operation it does not receive the plaintext of terminal I/O, directory listings, session history, audit data, egg configuration, or tunnel passkey assertions.
The relay still sees and controls routing metadata. It terminates TLS, serves the browser application, authenticates accounts, and chooses which wing connection receives a message. The threat model below distinguishes an honest-but-curious relay from a fully compromised relay.
Wingthing uses X25519 ECDH, HKDF-SHA256, and AES-256-GCM in two domains:
| Domain | Client key | Wing key | HKDF info | Content |
|---|---|---|---|---|
| PTY | Browser identity key, retained for the tab | Persistent ~/.wingthing/wing_key |
wt-pty |
Keystrokes and terminal output |
| Tunnel/signaling | Browser/native client identity key | Persistent ~/.wingthing/wing_key |
wt-tunnel |
Wing APIs, encrypted PTY controls, and WebRTC SDP |
| Direct control | Ephemeral WebRTC certificate negotiated in pinned signaling | Ephemeral WebRTC certificate | WebRTC DTLS | Remote MCP operations and results |
Encrypted messages are base64(nonce || ciphertext || GCM tag). A modified
ciphertext fails authentication.
The browser and native tunnel client pin the first public key seen for a wing ID
and reject later changes. This is trust on first use (TOFU): it detects accidental
rotation and later control-plane substitution while trusted client code is still
running. It does not make a malicious first connection safe. Browser pins live in
site storage; native CLI pins live in ~/.wingthing/known_wings.json.
The client key changes, but the wing's X25519 key is persistent. Someone who
records an old exchange and later steals wing_key can derive that old session's
key. The current protocol therefore provides unique per-client/per-tab keys, but
not forward secrecy against later wing-key compromise. Forward secrecy requires
signed ephemeral wing session keys or an authenticated handshake such as Noise.
Terminal input/output, resize, and kill operations are encrypted. The wing rejects plaintext resize and kill messages received from a relay. P2P resize may travel directly over WebRTC's authenticated DTLS channel.
Session start/attach routing, session IDs, timing, sizes, disconnects, and lifecycle messages remain visible to the relay. The protocol does not yet bind every envelope field into AEAD associated data or maintain a per-direction replay counter.
For a direct-only hosted account, the relay accepts only bounded tunnel purposes for wing discovery, WebRTC signaling, and passkey authentication. The wing checks the coordinator-visible purpose against the decrypted inner message before it responds. General tunnel control and PTY start/attach are denied before forwarding. The wing advertises this purpose-binding capability at registration; direct-only coordination to an older wing is denied with an upgrade error.
wt wing lock requires at least one valid passkey public key pinned in the local
wing.yaml. If no key is present, the explicit local lock command fetches the
logged-in user's registered key and pins it; locking fails if that cannot be done.
Runtime relay envelopes never auto-enroll a key into a locked wing.
Tunnel authentication uses a two-step ceremony:
passkey.auth.beginasks the wing for a challenge.- The wing creates a random one-time challenge bound to the relay user ID and the client's X25519 public key.
- The browser performs a
webauthn.getceremony with user verification required. passkey.auth.finishreturns the assertion through the encrypted tunnel.- The wing consumes the challenge once and validates the challenge, type, origin, RP ID hash, cross-origin flag, user-presence and user-verification flags, P-256 signature, and locally pinned key ownership.
- The issued token is bound to the same user ID and X25519 client key.
PTY start and reattach use the same validation rules with fresh wing-generated challenges. Locked starts, controller reattaches, and spectator attaches are rejected when the relay user has no matching locally pinned key.
Tokens are held only in wing memory. A restart revokes them; auth_ttl can impose
an earlier expiry. A token issued to one browser key or relay user does not work
for another.
Manage local approval with:
wt wing lock
wt wing allow --email user@example.com
wt wing allow --all
wt wing revoke user@example.com
wt wing unlockThe relay stores passkey public keys and credential IDs for account registration and explicit local approval flows. Public keys are not secrets, but key data read from the relay is trusted only when a person deliberately pins it locally. For a high-assurance deployment, verify the key over another trusted channel.
Each egg socket and token is readable only by the local OS user. Processes running
as that user can bypass MCP and invoke wt directly, so local MCP principals are
an accident-prevention and attribution boundary, not hostile same-UID isolation.
wt mcp stdio forwards to the already-running personal wing's control.sock in
its WINGTHING_DIR. When the canonical socket address exceeds 103 bytes, the
wing instead uses /tmp/wt-<uid>/<sha256-of-canonical-state-directory>.sock.
The runtime parent must already be owned by that UID, mode 0700 and not a symlink,
or the wing creates it with those permissions; unsafe existing parents are
refused. A mode 0600 control.socket-path file in the state directory records
the address for inspection. Clients derive the address independently rather
than trusting that file, so aliases find the same wing and different state
directories remain isolated. Neither the user's state directory nor TMPDIR
changes. The state directory is mode 0700 and the socket mode 0600;
the wing checks the peer UID with getpeereid on macOS and SO_PEERCRED on Linux.
The handshake negotiates the control protocol and wing ID. Shared and organization
wings do not serve this socket. Stdio never starts a wing or opens the session store.
wt wing start --local-only creates an account-free personal wing. Its owner ID
is persisted atomically in mode 0600 local-owner.yaml inside WINGTHING_DIR
and reused on every boot. This mode reads no relay token and starts no relay,
WebRTC or browser listener. Configured relay connectivity is an independent
adapter: a relay outage or 401 cannot shut down the local runtime.
Relay enrollment must explicitly bind a relay URL and authenticated user ID to the existing local owner. The binding data model preserves the local owner ID and egg ownership; loading an unrelated account token does not create that binding. The enrollment command for an account-free wing is future work.
The wing resolves --client NAME using its clients.yaml. Sessions are owned by
the wing's persistent owner; the unnamed/default client uses that owner's
principal. On account-backed wings this preserves the existing user principal;
on account-free wings web/remote ownership requires the explicit enrollment
binding above. Older ownerless sessions with an empty or default principal
retain their personal-wing compatibility scope.
Named MCP clients see and control their separate logical principals unless mapped
to a common owner. The human CLI still sees all sessions. Every
MCP tool call appends a timestamp, principal, tool, target, decision, and argument
digest to ~/.wingthing/mcp-audit.log. ~/.wingthing/clients.yaml can require an
explicit client, restrict grants, and bound sessions/spawns. Real isolation between
hostile local clients still requires different OS users or client sandboxes.
The wing derives native direct authority from the coordinator-authenticated user,
organization role, wing-local admin list, configured paths, and the local
direct_mcp policy. The caller cannot supply its own principal, role, grants, or
paths in a tool request.
The compatible default grants every operation currently reviewed for the direct surface, but does so with a non-nil explicit grant set and positive wing-enforced bounds: eight live direct-MCP terminal sessions and sixty spawns per hour per principal. The rolling spawn window is shared across reconnecting data channels in one wing process. It is a process-lifetime guardrail, not a durable billing quota.
Operators can narrow the direct surface in wing.yaml:
direct_mcp:
allow_grants: [capabilities.read, terminal.read, agent.read]
max_sessions: 4
max_spawns_per_hour: 20Use deny_grants instead of allow_grants to subtract from the compatible default,
or set disabled: true. Allow and deny cannot be combined. Unknown fields, unknown
grant names, negative or excessive bounds, and malformed policy fail wing startup or
SIGHUP reload rather than falling back to unrestricted direct access. Omitting the
entire section preserves existing wing.yaml files.
Organization owners and wing-configured admins can use every configured path. Ordinary members see only legacy-open paths and paths tagged with their email; their sessions are owner-scoped and use the sealed shared-host boundary. Existing OAuth shared-roost identities with an empty organization role retain member privilege, while an empty role outside shared-roost mode fails closed.
Roost policy roots and their ancestors must not be writable through any other path granted to a session, including Linux bind mounts, macOS firmlinks, or shares. Policy pins protect pathnames, not inodes, so an alias can expose a root masked at its configured path.
Coordinator-derived user and organization identity has a 15-minute maximum lifetime
on a direct data channel. The wing closes the channel when that lease expires, so
continued use requires a fresh access-filtered discovery and signaling exchange.
This bounds the effect of organization membership revocation. Wing-local lock,
passkey, grant, path, and bound changes are evaluated on every request, including on
an already-open channel after a successful SIGHUP reload.
The authenticated shared-roost HTTP MCP adapter uses the same explicit reviewed wing-operation grant set and the same default per-user bounds. Its admission state is shared across OAuth clients and requests in the roost process, so reconnecting or switching between Codex and Claude does not reset the spawn window.
For a private OAuth gateway or all-in-one roost, WT_ROOST_ALLOWED_EMAILS is a
separate enrollment boundary. Exact, case-insensitive email matches are enforced when a browser login
finishes and again for cookies, device tokens, the wing inventory, relay access,
and MCP authorization. OAuth login requires the provider's current verified email
to match. OAuth proves that a provider controls an identity; it does not make every
identity accepted by that provider a roost member. If the list is empty, any
identity accepted by the provider can enroll. Internet-reachable roosts therefore
need either an explicit list or an equivalent restriction at the provider or
ingress.
Each wing can independently refuse hosted payload relay, even when its account has hosted relay access or is connected to a self-hosted roost:
hosted_relay: denyOmitting the field is the compatibility value allow. The setting is restart-bound
so registration, session reclaim, and message handling cannot observe different
policies in one daemon run. Use wt wing config set hosted_relay=deny, then restart
the wing.
An honest gateway checks the advertised policy before forwarding terminal starts, attaches, input, terminal authentication/control, or general encrypted tunnel payloads. The wing client also suppresses outbound session-attention metadata. It enforces the same decision locally, so an old, stale, or modified coordinator cannot cause those handlers to run. Bounded WebRTC discovery, signaling, and passkey coordination remains available; the wing decrypts each request and verifies that the declared purpose matches the inner message type.
Authorized wing roster entries and encrypted wing.info responses report the
effective hosted_relay value. Gateway denials create a content-free audit entry with
actor, wing, operation, and policy; wing-local denials append operation and policy to
~/.wingthing/policy-audit.log with mode 0600. Neither record includes command,
working directory, terminal bytes, or tunnel payload.
This opt-out prevents payload handling by conforming gateway and wing binaries. It does not make hosted browser JavaScript safe against a malicious service, and an old gateway can still observe a connection attempt before the new wing rejects it. Use a native client over a private network or a self-hosted coordinator when the hosted service itself is outside the trust boundary.
hosted_relay: deny is not a downgrade-compatible security policy. A wing binary
from before this field existed ignores it and resumes the historical relay behavior.
Do not roll such a wing back while this setting is part of the security boundary:
stop the wing or isolate it from the coordinator until a conforming binary is
restored. Rolling back only the gateway is different—the current wing still enforces
the denial locally, although the old gateway can observe the attempted connection.
On a dedicated sandbox VM, wt egg ... --unsandboxed and
wt mcp stdio --unsandboxed explicitly make the outer VM the agent boundary.
Local MCP requires allow_unsandboxed: true in the personal wing's wing.yaml;
the flag requests a profile and cannot override wing paths, grants, bounds, locks,
or ownership. The setting is off by default and is rechecked for every call.
Wingthing keeps terminal persistence and the control/audit plane but applies no
nested filesystem, network, syscall, or resource restrictions. The MCP server
announces outer-boundary mode and records it on every audit entry. Because the
agent has the authority of the wing's OS user, it is a trusted endpoint for the
encryption model: it may read Wingthing state or invoke local control paths. E2E
encryption protects bytes from the relay, not from a compromised client or wing.
The coordinator can observe:
- Account, organization, device-token, and passkey registration records.
- Wing ID/public key, org binding, lock state, and connection presence.
- WebRTC signaling size/timing, candidate network metadata, and IP/network metadata.
- For entitled relay sessions only: session IDs, selected agent and working-directory routing metadata plus start/attach/detach/exit timing and message sizes.
- Traffic availability: it can delay, drop, duplicate, or reroute messages.
With the as-built trusted client and wing, the coordinator does not receive plaintext:
- Terminal keystrokes or output.
- Encrypted resize/kill requests.
- Tunnel directory/session/audit/config payloads.
- Tunnel WebAuthn assertions and issued auth tokens.
Plaintext terminal and task history exists on the wing for replay and durability. Wingthing does not currently provide application-level encryption at rest; use OS disk encryption when that threat matters.
Native chat archives captured by eggs started before the transcript confinement
upgrade are unverified; upgrading the wing does not verify their source. After
those eggs exit, an administrator can remove chat.jsonl.gz and chat.meta from
each affected <state-dir>/eggs/<session-id>/ directory (~/.wingthing by default,
or the configured WINGTHING_DIR) to prevent their chat audit export.
An attacker controlling the hosted relay can:
- Read its database and all listed metadata.
- Forge account/JWT/control-plane identity, impersonate a wing route, or cause DoS.
- Present a false wing key on first use.
- Serve modified browser JavaScript that reads plaintext before encryption or uses an authenticated browser as a decryption oracle.
- Start sessions on an unlocked wing as an account the relay claims is authorized.
Consequently, the hosted browser application does not provide a malicious-service-resistant E2EE guarantee. TOFU pinning and locked-wing passkeys meaningfully protect against later database/routing errors and a relay binary that cannot modify already trusted client code, but they cannot protect against malicious JavaScript served by the same compromised service.
A native, reproducibly distributed client with an independently verified wing pin can make the malicious-relay boundary substantially stronger. Until that handshake and distribution story is complete, direct-path wording must still disclose the hosted client-distribution and first-use trust boundaries rather than imply a Signal-style server-compromise guarantee.
- Signed ephemeral wing session keys for forward secrecy.
- A user-verifiable pairing/fingerprint flow instead of TOFU alone.
- AEAD associated data binding wing, session, message type, direction, and request ID.
- Monotonic per-direction counters or another replay-defense construction.
- Persisted WebAuthn signature counters if cloned-authenticator detection becomes part of the passkey guarantee.
- A trusted native/browser-extension client path if malicious web delivery is in scope.
- A native passkey ceremony before direct control of locked wings.
- At-rest protection for local history if Wingthing chooses to offer that guarantee.
| Surface | Current protection |
|---|---|
| Local CLI/MCP | Same-OS-user permissions; named MCP guardrails and audit |
Trusted VM (--unsandboxed) |
Outer VM/network policy; no nested Wingthing sandbox |
| SSH attach | OpenSSH transport and host-key policy |
| Direct remote MCP | WebRTC DTLS; offer/answer protected by pinned encrypted signaling |
| Terminal content through relay | X25519/HKDF/AES-GCM application encryption |
| Tunnel wing APIs | X25519/HKDF/AES-GCM application encryption |
| Resize/kill | Encrypted tunnel or direct WebRTC DTLS |
| Locked-wing authentication | Wing-generated WebAuthn challenge, local key, client-bound token |
| Relay metadata | Visible by design |
| Browser code integrity | Trusted-service assumption |
| Forward secrecy | Not currently provided |
| Local history at rest | Plaintext on the wing |
Platform-native sandbox. No fallback, no silent degradation.
sandbox.New(cfg) → newPlatform(cfg) → EnforcementError if platform can't enforce
| Backend | Platform | Isolation Mechanism |
|---|---|---|
| Seatbelt | macOS | sandbox-exec with generated SBPL profiles |
| Linux Namespaces | Linux | CLONE_NEWUSER/NEWNS/NEWPID/NEWNET + seccomp BPF + cgroups v2 + rlimits |
If the platform cannot enforce the requested isolation, the egg fails with EnforcementError. No silent fallback.
Linux capability detection exercises the mount operations Wingthing depends
on inside a user+mount namespace. Creating CLONE_NEWUSER alone is insufficient
on Ubuntu 24.04: AppArmor may allow namespace creation and then move the child
into a profile that denies mounts. wt doctor reports that host state as
NOT AVAILABLE; sudo wt doctor --fix installs an executable-scoped AppArmor
profile. The fixer accepts a root-owned executable whose complete path is
root-writable-only, such as /usr/local/bin/wt. It refuses checkouts, user
homes, download directories, and temporary paths before writing the profile;
AppArmor path attachment does not bind the grant to a content hash.
_deny_init treats every policy operation as mandatory. It aborts before the
agent process exists when a mask, read-only bind, HOME boundary, jail mount,
pivot, old-root detach, or seccomp installation fails. After setup it reads
/proc/self/mountinfo and verifies every expected mask and writable hole. The
Linux E2E gate plants a unique readable canary inside a denied directory and
attempts the read from the agent's live namespace.
Agent profiles contribute the provider domains an agent usually needs. The mapping form can suppress those additions for a tightly scoped provider:
network:
domains: []
agent_domains: none
env:
- OPENAI_API_KEY
- WT_PROVIDER_BASE_URLWith WT_PROVIDER_BASE_URL=https://api.arliai.com/v1, the effective network
policy contains the exact host api.arliai.com. The default value for
agent_domains is merge, preserving existing scalar, list, and mapping
configurations. wt egg explain <agent> lists declared, automatic, derived,
and suppressed domains with their provenance.
Provider URLs use HTTPS. HTTP is accepted for loopback providers, and IP literals are accepted for loopback addresses. Userinfo in provider URLs is rejected.
| Feature | macOS | Linux |
|---|---|---|
| Deny paths (.ssh, .aws, etc.) | SBPL rules | tmpfs overlays |
| Write isolation (HOME read-only) | SBPL rules | bind-mount read-only + writable holes |
| Network deny | SBPL (deny network*) |
CLONE_NEWNET |
| Domain filtering | SBPL forces traffic through local CONNECT proxy | CLONE_NEWNET + inherited-FD loopback relay to CONNECT proxy |
| PID isolation | n/a | CLONE_NEWPID |
On macOS, every deny: path emits both filesystem-denial and Unix-socket
network-outbound rules. This matters for discoverable SSH-agent and control
sockets: blocking reads of the socket pathname alone does not block connect(2)
to an already-open endpoint. Explicit socket allows are emitted first, so an
overlapping mandatory deny still wins. Linux masks denied socket paths inside
the mount namespace.
BPF filter blocks 27+ syscalls across these categories:
| Category | Blocked Syscalls |
|---|---|
| Filesystem | mount, umount2, pivot_root |
| Module loading | init_module, finit_module, delete_module |
| Reboot/swap | reboot, swapon, swapoff, kexec_load, kexec_file_load |
| Process debug | ptrace |
| Namespace escape | setns, unshare |
| Container escape | open_by_handle_at (Shocker CVE-2014-3519) |
| eBPF / perf | bpf, perf_event_open, userfaultfd |
| Kernel keyring | keyctl, add_key, request_key |
| Misc privilege escalation | kcmp, lookup_dcookie, acct |
| Time manipulation | clock_settime, settimeofday |
| x86-only | iopl, ioperm, modify_ldt (amd64 only) |
Installed in _deny_init after mounts are complete, inherited by child processes. Prevents the agent from undoing deny-path overmounts.
Two enforcement layers: cgroups v2 for real limits, prlimit as belt+suspenders.
| Mechanism | What it limits | Config field |
|---|---|---|
cgroups v2 memory.max |
Real memory (RSS) | resources.memory |
cgroups v2 pids.max |
Process tree count | resources.max_pids |
| prlimit RLIMIT_AS | Virtual address space (4GB floor for JIT) | resources.memory |
| prlimit RLIMIT_CPU | CPU time | resources.cpu |
| prlimit RLIMIT_NOFILE | Open file descriptors | resources.max_fds |
Cgroups v2 requires delegation from the init system (systemd usually provides this). When unavailable, falls back to prlimit-only with a log warning. No defaults. Limits only apply when explicitly configured in egg.yaml.
macOS Seatbelt does not support resource limits.
Linux keeps CLONE_NEWNET for every network mode. The namespace has no default
route and receives only declared loopback listeners: an HTTP CONNECT proxy for
domain-filtered tunnels and explicit network.local_ports forwards for
host-local services. Removing HTTPS_PROXY therefore does not restore network
access.
CONNECT can carry arbitrary TCP bytes to any port on an allowed host; the
current policy filters the destination host, not its port. Software must honor
the HTTP proxy variables or explicitly speak CONNECT. There is no SOCKS proxy or
general routed interface for ordinary raw-socket clients, and UDP, ICMP, and
other non-TCP protocols are unavailable. network: "*" permits any TCP target
presented through CONNECT, but still creates no general route. Host-side CONNECT
and loopback relays are capped at 256 simultaneous
tunnels per sandbox; CONNECT headers and upstream dials also have finite timeouts.
Destroying the sandbox closes its inherited bridge and active proxy tunnels.
To prevent an allowed public hostname from becoming an SSRF route through DNS
rebinding, named domains may not resolve to loopback, link-local, RFC1918, IPv6
ULA, or CGNAT/tailnet space. An operator who intentionally needs a private
destination can list its IP literal; host-loopback services should use
network.local_ports.
On WSL2 the same namespace relay is supported. If a particular WSL kernel rejects one of the filesystem bind mounts, Wingthing names the failed operation and refuses to launch; run inside a privileged Linux container or VM when an outer filesystem boundary is required.
Older Linux releases did not keep the network namespace for every policy. After
upgrading, a workload that depended on raw sockets, UDP/ICMP, or an undeclared
destination will fail closed. network: "*" restores any-destination CONNECT
traffic, but intentionally does not restore a general route. A workload that
needs a service on the host loopback must declare its TCP port under
network.local_ports; listing only localhost does not forward every host
port. Use wt egg explain <agent> and, temporarily, network.mode: observe to
diagnose missing CONNECT destinations. Observe mode does not restore raw
network protocols.
Claude needs ~/.claude/ writable. The sandbox mounts it read-write. A sandboxed task can read credentials there, but domain filtering limits where it can send them.
Write isolation makes HOME read-only but still readable. Add deny:~/.secrets to block specific paths.
~/.claude/ and similar dirs must be writable for the agent. A task could inject hooks into settings.json that persist after the session.
macOS agents can consume unbounded CPU and memory.
See docs/archive/egg-sandbox-design.md for full design details, agent profiles, and SBPL reference.
The filesystem DSL retains ro:, rw:, deny: and deny-write: rules;
base: retains file/named inheritance, section masks and base: none. Defaults
make HOME read-only with explicit workspace/cache/agent write holes and sensitive
path denies. This is HOME write isolation, not a filesystem allowlist: Linux's
fresh mount namespace starts with a cloned view of the host filesystem, and
OS-user-writable locations outside HOME remain writable unless denied.
Scalar/list network and env forms remain compatible. The optional mapping is:
network:
domains: [github.com]
local_ports: [11434]
mode: enforce
agent_domains: mergeagent_domains: none suppresses automatic profile domains; merge is the
default. Observe mode audits missing CONNECT destinations without restoring raw
networking. Unknown fields, invalid modes and invalid ports fail configuration.
domain:port and *:port are not accepted policy forms. The "*" union selects
the broadest platform policy, not one uniform raw-networking mode: Linux allows
any CONNECT destination through a route-less namespace, while macOS emits no
Seatbelt network deny for full access. Declared host-local TCP ports remain
separate from domain matching. Agent credentials that the agent can read and
writable agent configuration remain endpoint risks, even with filtered egress.
wt egg ... --unsandboxed and wt mcp stdio --unsandboxed are explicit startup
choices for a dedicated VM boundary. Local MCP requires an independently running
personal wing with allow_unsandboxed: true in its wing.yaml. They keep durable sessions, ownership
checks and audit, but apply no nested filesystem, network, syscall or resource
restriction. Capabilities/session JSON and audit report outer-boundary; a model
cannot toggle it per tool call. Host and hypervisor administrators remain trusted.
A free account uses wt mcp connect for direct remote control; an account with
hosted-relay access may separately choose the browser relay. Neither choice
changes the VM boundary or creates automatic transport fallback.