Skip to content

feat: opt-in Tailscale support for sandbox containers - #89

Open
wbtracey wants to merge 5 commits into
dtormoen:mainfrom
wbtracey:feat/tailscale-support
Open

feat: opt-in Tailscale support for sandbox containers#89
wbtracey wants to merge 5 commits into
dtormoen:mainfrom
wbtracey:feat/tailscale-support

Conversation

@wbtracey

@wbtracey wbtracey commented Jul 28, 2026

Copy link
Copy Markdown

What

Opt-in --tailscale lets a sandbox join the operator's tailnet to reach private services (internal APIs, databases, staging boxes), while the Squid allowlist stays the default and only egress for everything else. Off by default; enabled per-command (run/shell/add/retry) or tailscale = true in tsk.toml.

Why

Some tasks need the agent to reach a private service that isn't on — and shouldn't be added to — the public allowlist. Tailscale gives a scoped, ACL-controlled path to those services without opening general internet egress.

Security model

With Tailscale enabled, the enforced egress boundary for tailnet traffic is your Tailscale ACLs + the auth key's tags, not Squid. The sandboxed agent runs non-root but holds NET_ADMIN and owns the tailscaled control socket, so tsk's config flags (accept_routes, the up_args denylist) are defense-in-depth, not a hard boundary — a determined agent can reconfigure its own tailnet egress at runtime. This is called out prominently in the docs; the recommended setup is a reusable, ephemeral, tagged auth key — minted once and reused across tasks, scoped by tag + ACL (ephemeral so sandbox nodes auto-clean). A per-task key would be impractical for a task runner; a future enhancement could mint short-lived keys automatically from a Tailscale OAuth client.

Non-tailnet egress stays locked to Squid in every path (the internal no-gateway network means it fails closed even with NET_ADMIN): no direct egress, allowlist enforced.

How it works

  • Auth key is read on the host at container start ($TS_AUTHKEY / configurable env var / key file), passed in to join, then unset and the agent exec'd so the in-container agent can't recover it from /proc/<pid>/environ. Never written to the task DB or image. (It does live in Config.Env, readable via docker inspect on the host, for the container's lifetime — treat host access as trusted.)
  • Modes: Docker with a host /dev/net/tun → transparent kernel mode (tailnet in NO_PROXY, incl. IPv6 fd7a:115c:a1e0::/48). Rootless Podman → userspace mode via tailscaled's SOCKS5 proxy (ALL_PROXY=socks5h://localhost:1055). Docker Desktop on macOS (no host TUN) is not supported.
  • Name resolution: tailnet device names are snapshotted from the host's tailscale status and injected into the container's /etc/hosts via --add-host — no in-container privilege needed. Device names only, not full MagicDNS. Opt out with tailscale_host_aliases = false.
  • Guards: accept_routes is opt-in (default false); isolation-weakening up_args (--exit-node, --advertise-*, --accept-*, --netfilter-mode, incl. single-dash spellings) are rejected at task creation; --tailscale requires network isolation.
  • The proxy allowlist gains Tailscale's control/DERP domains (.tailscale.com/.tailscale.io:443); Tailscale tasks get their own proxy fingerprint.

Config

tailscale, tailscale_auth_key_env, tailscale_auth_key_file, tailscale_hostname, tailscale_accept_routes (default false), tailscale_host_aliases (default true), tailscale_up_args.

Testing

  • Unit tests cover auth-key resolution (env/file precedence, error paths, ~ expansion), shell-quote injection safety, config merge/precedence, the up_args denylist (incl. single-dash), the network-isolation requirement, kernel vs userspace proxy env, /etc/hosts alias building, and hostname sanitization.
  • Manually verified end-to-end against a live tailnet (rootless Podman): join, reachability by name and IP, and that non-tailnet egress stays blocked (direct egress fails, allowlist enforced) with NET_ADMIN + Tailscale active.

Docs

README.md "Tailscale (optional)" and docs/network-isolation.md.

Limitations

Full MagicDNS / split-DNS / subnet-router hostnames aren't covered (device names only); self-hosted control planes (Headscale / --login-server) aren't supported (the allowlist only opens Tailscale SaaS domains); exit-node egress is intentionally blocked; userspace-mode HTTP-to-tailnet needs an explicit --socks5-hostname (non-HTTP is transparent); Docker Desktop on macOS is unsupported.

🤖 Generated with Claude Code

@wbtracey
wbtracey force-pushed the feat/tailscale-support branch from 87a80e0 to 0d5cb89 Compare July 28, 2026 06:51
Sandboxes can join the user's tailnet so agents can reach private
services (internal APIs, databases, staging boxes), while the Squid
allowlist stays the default and only egress for everything else.
Enable per-command with `--tailscale` (run/shell/add/retry) or
`tailscale = true` in tsk.toml. Off by default.

Security model: with Tailscale on, the enforced egress boundary for
tailnet traffic is your Tailscale ACLs + the auth key's tags, not Squid
— the sandboxed agent holds NET_ADMIN and the tailscaled socket, so
tsk's config flags are defense-in-depth, not a hard boundary. Recommended:
a reusable, ephemeral, tagged auth key (minted once, scoped by tag + ACL;
reused across tasks). Non-tailnet egress stays locked to Squid in every
path (no direct egress; fails closed).

- Auth key is read on the host at container start ($TS_AUTHKEY / env var
  / key file), passed in to join, then `unset` and the agent `exec`'d so
  the in-container agent can't recover it from /proc/<pid>/environ; it is
  never written to the task DB or image (it does live in Config.Env,
  readable via `docker inspect` on the host, for the container lifetime).
- accept-routes is opt-in (`tailscale_accept_routes`, default false);
  isolation-weakening `tailscale_up_args` (--exit-node, --advertise-*,
  --accept-*, --netfilter-mode, incl. single-dash forms) are rejected at
  task creation; `--tailscale` requires network isolation.
- Kernel mode (Docker + host /dev/net/tun) routes the tailnet
  transparently (tailnet in NO_PROXY, incl. IPv6 fd7a:115c:a1e0::/48);
  rootless Podman runs userspace mode via tailscaled's SOCKS5 proxy
  (ALL_PROXY). Docker Desktop on macOS (no host TUN) isn't supported.
- Tailnet device names resolve via host-snapshotted /etc/hosts aliases
  (--add-host); opt out with `tailscale_host_aliases = false`. Device
  names only, not full MagicDNS.

Docs: README.md "Tailscale (optional)" and docs/network-isolation.md.

Co-Authored-By: Claude <noreply@anthropic.com>
@wbtracey
wbtracey force-pushed the feat/tailscale-support branch from 0d5cb89 to 22887dd Compare July 28, 2026 06:59
wbtracey added 4 commits July 31, 2026 10:27
Sandbox nodes joined the tailnet but were never cleaned up, so the
Tailscale admin console accumulated leftover tsk-* devices — the node's
lifetime depended entirely on whether the auth key happened to be
ephemeral, which a hand-made key easily gets wrong (non-ephemeral and/or
untagged, attributing the node to the operator's personal identity).

Add opt-in per-task key minting: when a Tailscale API credential (a PAT
or an OAuth client) is configured, tsk mints a fresh single-use,
ephemeral, tagged auth key per task via the Tailscale API at container
start. The node is then always ephemeral (Tailscale auto-removes it a few
minutes after the sandbox stops — robust even if tsk crashes) and always
tagged (so its access is scoped by the tag's ACL, not by anyone's personal
identity). The existing bring-your-own-key path is retained as a fallback,
now with an in-container warning when the joined node is untagged.

Config (all opt-in; minting only runs when one is set):
- tailscale_api_key_env / tailscale_api_key_file  (PAT)
- tailscale_oauth_client_id / tailscale_oauth_secret_env / _file  (OAuth)
- tailscale_tailnet (default "-") and tailscale_tags (default
  ["tag:tsk-sandbox"])

Security: the API credential is read host-side only and is never written
to the container environment, the task config snapshot, or logs; the
minted per-task key flows through the existing unset+exec scrub, and
MintCredential redacts its Debug output. OAuth takes precedence over a
PAT; a configured OAuth secret without a client id now surfaces a clear
error instead of silently falling back.

README documents the minting setup (PAT vs OAuth) and node cleanup.
Includes the design spec and implementation plan under docs/superpowers/.
@wbtracey
wbtracey force-pushed the feat/tailscale-support branch from b538b49 to fbf8e44 Compare July 31, 2026 17:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant