Skip to content

Security Model Overview

Wayland Core’s security posture is built around a fail-closed philosophy: every enforcement layer defaults to the most restrictive behavior. Operators must explicitly opt out of protections; there is no mechanism for a plugin or late code path to quietly weaken the session’s isolation.

The enforcing layers that are active today are:

  • OS-native sandbox: every model-driven shell command runs inside a platform-native process isolation backend
  • Egress gate: every outbound HTTP call passes a single gating point, on by default
  • Approval modes and plan mode: destructive tools require explicit human consent before execution
  • Budget caps: session-level and per-user token/cost limits that hard-block at the limit

A fifth layer - the explicit-grant ACL (wcore-permissions) - exists in the codebase with full tests but is not yet wired into production call sites. See Permission Model for its design and current status.


Every shell command dispatched by the agent runs inside an OS-native sandbox selected by default_for_platform() in crates/wcore-sandbox/src/lib.rs. The selection order is:

  1. WAYLAND_SANDBOX=docker - Docker container (cross-platform opt-in)
  2. Linux: Bubblewrap (bwrap); Bubblewrap + Landlock LSM; Bubblewrap + libseccomp strict filter
  3. macOS: sandbox-exec (Seatbelt profile)
  4. Windows: AppContainer + Job Objects

If the selected backend is unavailable and the operator has not set WAYLAND_ALLOW_NO_SANDBOX=1, the engine selects FailClosedBackend. Every execute() call on that backend returns an error:

sandbox UNAVAILABLE and unsandboxed execution is not permitted -
set WAYLAND_ALLOW_NO_SANDBOX=1 to allow unsandboxed execution.

The double-key requirement (WAYLAND_SANDBOX=none AND WAYLAND_ALLOW_NO_SANDBOX=1) prevents a stray environment variable from stripping isolation. When an unsandboxed session is running, the engine logs a rate-limited warning at most once every 60 seconds (DEGRADED_WARN_INTERVAL) so the degraded state stays visible throughout a long session.

Each backend receives a SandboxManifest that declares what the process is permitted to do:

# Conceptual manifest fields (passed programmatically, not from a TOML file)
fs_read_allow = ["/home/user/project"] # read-only bind mounts / Landlock rules
fs_write_allow = ["/tmp/workspace"] # read-write mounts
network = "deny" # inherit | deny | allow_hosts([...])
syscall_policy = "strict" # inherit | strict (Linux seccomp only)
timeout = { secs = 30 }
max_memory_bytes = 536870912 # 512 MiB; advisory on most backends
max_cpu_secs = 60

Backends always scrub the host environment before injecting the declared env entries (env -i style). Windows AppContainer filesystem ACLs are not yet wired; the interim behavior is safe default-deny. On Linux, AllowHosts network policy is not yet supported by the bwrap backend (no DNS gate).

See the sandbox source for per-backend details.


All outbound HTTP in the workspace is routed through a single EgressClient in crates/wcore-egress. A Clippy disallowed-methods lint bans reqwest::Client::new and reqwest::Client::builder everywhere else - any new code that bypasses the gate fails the build.

The gate is enforced by default. Disabling it requires both:

  1. security.enabled = false in ~/.wayland-core/config.toml
  2. The --i-accept-exfil-risk CLI flag at the same invocation

Disabling with only the config change is silently rejected and emits a tracing::warn!. The comment at crates/wcore-config/src/config.rs documents: “never a bare env var - supply-chain hazard.”

Every request is classified before the TCP connection is opened:

VerdictConditionOutcome
AllowDestination is allowlisted or localProceeds silently
AskNew external destination, not exfil-shapedOperator consent via console doorbell
ExfilExfil-class destinationAlways gated, never auto-allows
(local)Loopback, RFC 1918, CGNAT, link-local, IPv6 ULAAlways allowed

Exfil classification triggers (any one is sufficient):

  • POST/PUT/PATCH body to a non-allowlisted external host
  • GET/HEAD where path + query > 96 chars OR a 24+ character base64/hex-ish token appears in path or query
  • Any request to a shared-platform host not exact-host-allowed

Shared-platform hosts can never be apex-allowlisted. Adding amazonaws.com to security.egress_allow is silently rejected; a Bedrock endpoint must be exact-host-allowed (e.g. bedrock-runtime.us-east-1.amazonaws.com). Categories include code-sharing platforms (GitHub raw, Gist, Pastebin), object storage (S3, Azure Blob, GCS, R2), serverless/preview domains (workers.dev, vercel.app, netlify.app, ngrok.io), and OOB canary hosts (requestbin.com, webhook.site, burpcollaborator.net, interact.sh).

  • Redirects disabled on streaming and tool clients - prevents credential re-attachment via a 302 redirect
  • Deny fires before network: a denied request never opens a TCP connection
  • Timeouts: connect 30 s, read 300 s, total 300 s for tool clients
  • SSRF blocking at Layer 0 in wcore-tools/src/url_safety.rs (separate from the egress Layer 1)

crates/wcore-agent/src/egress/defaults.rs seeds the allowlist with approximately 30 LLM provider, tool backend, and package registry domains (anthropic.com, openai.com, github.com, tavily.com, crates.io, pypi.org, and others), plus the active provider host derived from config.base_url. Operator additions go in config.toml:

[security]
# enabled = true ← default; omitting = enforcing
egress_allow = ["my-internal-tool.example.com"]

The engine has three session modes, selectable via --mode or the protocol SessionMode field:

ModeBehavior
DefaultAll destructive tools (shell, file writes, etc.) require explicit human approval before each call
AutoEditFile writes are auto-approved; shell commands still require approval
ForceAll approval gates disabled; the engine dispatches without waiting

Force mode accepts the aliases yolo, dangerously_skip_permissions, and dangerously_skip_sandbox_and_permissions so foreign-agent hosts (Claude Code, Codex, Gemini) can drive the engine without code changes.

Plan mode (EnterPlanMode / ExitPlanMode tools, or --plan) is a read-only mode. When active, the engine guard at engine.rs blocks any write tools from executing. The plan mode flag persists to ~/.wayland-core/plan.toml. Use this for auditing what an agent intends to do before allowing writes.


Budget caps provide a hard upper bound on what a session can consume. They are configured in two blocks:

[budget]
max_wall_time_secs = 600 # wall-clock session timeout
max_tool_runtime_secs = 120 # accumulated tool execution time
max_processes = 8 # concurrent subprocess count
max_agent_depth = 4 # delegation depth cap
max_tokens_in = 200000
max_tokens_out = 16384
max_cost_usd = 1.50

All caps are optional. An unset cap places no limit. Child agent views propagate counters up to the root, so a parent budget sees the full session rollup. A cap on a child view overrides (does not relax) the parent.

When any cap is exceeded, the engine emits a BudgetExceeded { reason, observed, limit } event on the protocol stream. This event requires no capability flag.

[session_cap]
per_session_tokens = 500000
per_session_usd = 2.00
per_user_daily_usd = 10.00 # resets at midnight UTC per (user_id, UTC-day)

BudgetTracker emits a CapWarn event at 80% of the strictest configured cap and a hard CapBlock at the limit. A blocked charge does not mutate counters.

Note: the USD value passed to BudgetTracker::charge is trusted from the caller; there is no cryptographic attestation from the provider. Cost tracking is honest for non-adversarial use but is not tamper-proof against a buggy or malicious caller.


The design addresses these categories of risk:

ThreatMitigating layer
Model-driven shell command escapes to host filesystemOS sandbox (bwrap / sandbox-exec / AppContainer); fail-closed when backend unavailable
Agent exfiltrates secrets via HTTPEgress gate: single chokepoint, shared-platform blocklist, exfil classifier
Model-driven redirect re-attaches credentialsRedirects disabled on EgressClient streaming and tool clients
SSRF against cloud metadata endpointsURL safety layer (wcore-tools) at tool surface; egress gate at network layer
Unconstrained spend or unbounded sessionsBudget caps with CapWarn at 80% and hard CapBlock at limit
Prompt injection via shell metacharactersshell_command_argv constructs commands in argv mode; Clippy bans sh -c with LLM-supplied strings outside that function
Secrets leaking into trace output28-pattern PII scrubber on all trace sinks (wcore-safety/src/pii.rs)
Secrets leaking into debug logsBearerToken Debug impl prints "<redacted>" for the signature field
Sandbox isolation silently droppedFailClosedBackend refuses execution; double-key opt-out required; rate-limited warn in degraded sessions