earl

Hardening

Network controls, sandbox limits, and a production checklist for deploying Earl securely.

This page covers the configuration knobs that matter before running Earl where other people or production systems depend on it.

SSRF protection

Earl blocks requests to private and loopback IP ranges in security/ssrf.rs. This runs on every outbound request — HTTP, GraphQL, gRPC, and OAuth token fetches. There is no configuration option to disable it.

The blocked ranges:

RangeDescription
127.0.0.0/8IPv4 loopback
10.0.0.0/8RFC 1918 private
172.16.0.0/12RFC 1918 private
192.168.0.0/16RFC 1918 private
169.254.0.0/16Link-local, includes 169.254.169.254 (AWS metadata)
100.64.0.0/10Shared address space (RFC 6598)
192.0.0.0/24IETF protocol assignments
198.18.0.0/15Benchmarking range
240.0.0.0/4Reserved
::1/128IPv6 loopback
fc00::/7IPv6 unique local
fe80::/10IPv6 link-local
fec0::/10IPv6 site-local
fd00:ec2::254Cloud metadata alias
::ffff:169.254.169.254Cloud metadata alias (IPv4-mapped)

IPv4-mapped IPv6 addresses like ::ffff:10.0.0.1 are unwrapped and checked against the IPv4 blocked ranges.

You can verify the behavior by trying to call a template pointed at a blocked address — Earl will return a "blocked potentially unsafe IP address" error before the connection is attempted.

Network egress allowlist

The SSRF layer blocks inward — private ranges that shouldn't be reachable. The egress allowlist blocks outward — it restricts which public hosts Earl can contact at all.

Without an allowlist, Earl can reach any public IP. With one, any request to a host not on the list is rejected.

Configure it in ~/.config/earl/config.toml using [[network.allow]] entries. Each entry requires all four fields:

[[network.allow]]
scheme      = "https"
host        = "api.github.com"
port        = 443
path_prefix = "/"

[[network.allow]]
scheme      = "https"
host        = "api.stripe.com"
port        = 443
path_prefix = "/"

host is matched exactly. There is no wildcard support — api.github.com and api.stripe.com must be listed as separate entries. path_prefix = "/" allows all paths on the host; a more restrictive prefix like "/v1/" limits the allowlist to that path subtree.

Setting an allowlist is the most effective way to limit what a compromised template or unexpected parameter value can reach.

Write-mode confirmation

Write mode is the default. Any command without mode = "read" in its annotations prompts before executing:

Command `github.create_issue` is write-enabled.
Type YES to continue:

The call blocks until the user responds. In a terminal session this works as expected. In an automated pipeline — CI, a cron job, or an MCP server running unattended — the call either times out or hangs.

Pass --yes to bypass the prompt for a specific invocation:

earl call --yes --json github.create_issue --owner myorg --repo myrepo --title "Automated report"

--yes must come before the command name, after call.

A few things to consider before using --yes broadly:

  • It bypasses confirmation for the entire invocation, including any write commands in a chained sequence.
  • In MCP HTTP deployments, the policy engine can restrict write access per caller rather than relying on --yes. That's a better control surface for multi-user setups. See Policy Engine.
  • For stdio MCP, write-mode prompts appear as MCP log messages. Most agents don't render these to users, which means --yes is usually required for any write command to complete in a fully automated flow. If you want a human in the loop for write operations in MCP, use the HTTP transport with the policy engine instead.

Bash sandbox configuration

The Bash protocol runs scripts in a sandbox. The defaults are conservative:

bash {
  script = "..."
  sandbox {
    network          = false
    max_time_ms      = 30000
    max_output_bytes = 1048576
  }
}

network = false blocks all outbound connections from the script. This is the default. If you need network access for a specific script, set network = true — but note that SSRF blocking still applies. The script can't reach private IP ranges regardless.

max_time_ms kills the process if it runs longer than the specified number of milliseconds. The example above sets a 30-second limit. There is no default upper bound, so set this explicitly for any script that could stall.

max_output_bytes truncates stdout and stderr at the specified byte count. Useful for scripts that might produce large or unbounded output.

For any bash command invoked by an agent, network = false and explicit time/size limits are the right baseline. Expand from there only if the command's purpose requires it.

SQL read-only sandbox

SQL commands have two sandbox controls worth enabling by default:

sql {
  connection_secret = "myapp.db_url"
  query             = "SELECT id, name FROM users WHERE status = $1 LIMIT $2"
  params            = ["{{ args.status }}", "{{ args.limit }}"]
  sandbox {
    read_only = true
    max_rows  = 100
  }
}

read_only = true opens the connection in a read-only transaction. Write operations — including those inside stored procedures — fail at the database level, not at the application level. This is a hard constraint rather than a soft check.

max_rows caps how many rows are returned. Without it, an agent querying a large table with a broad predicate can return enough data to cause problems downstream. Set this to the largest result set that makes sense for the command's purpose.

SQL parameters go through the driver's parameter interface, not string interpolation, so they're protected against injection. Quote each Jinja expression as a string in the params array — Earl coerces types before sending the query. Placeholder syntax varies by database: $1, $2... for PostgreSQL; ? for MySQL and SQLite.

JWT auth and policy engine for HTTP deployments

When running Earl as an MCP HTTP server, configure JWT authentication before exposing it to any network other than localhost:

earl mcp http --listen 0.0.0.0:8977

Earl refuses to start this command without either [auth.jwt] configured in config.toml or --allow-unauthenticated passed. Do not use --allow-unauthenticated on a shared or production host.

A minimal JWT configuration using OIDC discovery:

[auth.jwt]
audience           = "https://api.yourcompany.com"
oidc_discovery_url = "https://accounts.yourcompany.com/.well-known/openid-configuration"

Once JWT is configured, add policy rules to control which callers can invoke which tools:

# Read-only GitHub access for all authenticated users
[[policy]]
subjects = ["user:*"]
tools    = ["github.*"]
modes    = ["read"]
effect   = "allow"

# Deny write-mode destructive commands for everyone
[[policy]]
subjects = ["*"]
tools    = ["github.delete_repo"]
modes    = ["write"]
effect   = "deny"

The policy engine uses deny-overrides: one deny beats any number of allows. If nothing matches, the call is rejected. See Policy Engine for the full rule syntax.

Production checklist

Before running Earl in production:

  • Templates are committed to a version-controlled repository with branch protection and restricted write access
  • Secrets are stored in the OS keychain or an external secret manager — not in template files, environment variables, or CI logs
  • An egress allowlist is configured in config.toml, restricted to the specific hosts Earl needs to reach
  • Every bash command has explicit max_time_ms and max_output_bytes limits set
  • Every SQL command uses read_only = true and a max_rows limit appropriate for the query
  • Write commands are reviewed and either use the policy engine to restrict write access, or --yes is only passed in contexts where a human has authorized it
  • For MCP HTTP deployments: [auth.jwt] is configured, --allow-unauthenticated is not in use, and at least one restrictive [[policy]] entry is in place
  • earl doctor runs clean on the production host
  • OAuth endpoint URLs in config.toml point to public identity providers, not to internal hosts that happen to respond on those ports

See also

On this page