I treated my AI agent like an untrusted network segment

I moved an AI agent off my own machine and onto a server behind Tailscale. Closing off inbound was easy. The real lesson was elsewhere: in a mesh, every connection runs both ways, and the boundary has to be drawn not only on the network but inside the server too.

Why I did this

The first time I ran Claude Code on my MacBook, I gave an AI agent access to my file system and a shell: the same file system that holds my SSH keys, my projects, my configs and years’ worth of work files.

It wasn’t that I distrusted the agent. The discomfort was more basic: I hadn’t drawn a trust boundary. On the network side, that’s always been the first thing I do: where does this segment end, and what does this ACL let through? My MacBook, on the other hand, was like a flat network, with everything on it at the same trust level. In any professional setting, I’d never accept that.

There was a more mundane problem too: I’d switch between the MacBook and the Fedora desktop on the same project, each with its own Node version and a half-finished .env. Both were symptoms of the same problem: my development environment didn’t live in one place, and none of its parts had a defined boundary around it.

Restricting where an agent runs is a much simpler and much more auditable problem than restricting what it can do.

I use Claude Code’s own permission mechanisms as well. But instead of relying on a single control, I wanted a second, independent layer: a boundary drawn around the agent, not inside it.

What I tried

Local VM (OrbStack). An Ubuntu VM on macOS: fast, with impressive integration. It solved isolation, but the environment stayed tied to the MacBook: it stopped when I closed the lid and couldn’t be reached from Fedora. It wasn’t wasted, though: it’s where I rehearsed everything below before moving it to a real server.

VPS + plain SSH. Isolation and continuity, sorted. But port 22 was open on a public IP, and every device meant another authorized_keys entry to manage. I’d drawn the boundary in the right place and then put the door where everyone could see it.

Tailscale. I changed the model, not the tool: I set up Tailscale not as a “remote access VPN” but as my own private network layer that my devices sit on.

From a network perspective

The quickest way to understand Tailscale is to break it down into concepts a network engineer already knows:

  • Control plane (centralized). Authenticates identities and distributes keys and policy to the nodes. It carries no traffic.
  • Data plane (peer to peer). Packets flow directly between devices over WireGuard tunnels; devices behind NAT find each other with STUN and hole punching.
  • Fallback (DERP). If a direct path can’t be established, traffic goes through a relay; the relay carries the packets but can’t see inside them.
Classic VPN vs. meshOn the left, a hub-and-spoke topology where all traffic goes through a central VPN gateway; on the right, a mesh where the control plane is centralized and the data plane is peer to peer.BEFORE: hub-and-spokeAFTER: meshVPNGWmacandiossrvcontrol + data plane in one placecoordinationmacandiossrvdashed: control plane · solid: data plane
What changed wasn't the tool but the topology itself.

In practice, port forwarding stopped being a thing for me, and the answer to “who can reach this?” is an identity, not an IP range. There’s also a less obvious detail: the policy is written centrally but enforced separately on every node. So it’s not a perimeter firewall but a distributed host firewall. That will matter later on.

The final architecture

One rule: the session lives on the server, the client is just a window. Claude Code, the files and the tmux session all live on the ubuntu server. tmux is what makes this work: it ties the agent to the server, not to my connection. I can start a task on the MacBook, check on it from my phone on the go and pick it up from Fedora in the evening. Even if I close the MacBook’s lid or the connection drops, Claude keeps working, and when I come back everything is where I left it. Whether it’s the terminal or VS Code on macOS and Fedora, or Termius on my phone, I always land in the same session. And I draw the boundary in three places:

Target architectureA tailnet with a single identity, Tailnet Lock on. A desktop running the CLI (macOS or Fedora) is the primary signer, an iOS device signing via QR code is the backup; Android has no signing rights. All three clients can only SSH to the ubuntu server over tcp 22. On the server, devuser runs the agent without sudo, sysadmin handles administration with password-protected sudo, and root is denied from the tailnet. The server has no signing rights, and there are no rules from the server to the clients or between clients.TAILNET · one identity · Tailnet Lock: ONmacOS / Fedoraprimary signer · CLIterminal + VS CodeiOS / iPadOSbackup signer · QR codeTermiusAndroidno signing rightsTermiussigning: desktop → new devicetcp:22 · acceptubuntu · tag:dev-servercurrent version · auto-updatedevuserClaude Code · no sudosysadminadmin · sudo with passwordroot: denied from the tailnetno signing rights · no operatorubuntu → clients: no ruleclients ↔ clients: no rule
Where the agent runs is the least trusted place on the tailnet.
  • On the network. The tailnet policy: who, to which port, in which direction, as which user.
  • On the devices. Tailnet Lock: my devices decide who can join the tailnet, not the control plane.
  • Inside the server. The agent and the admin are separate users: even if the agent is compromised, it can’t reach system administration.

Below are only the lines that carry a decision; this isn’t a step-by-step setup.

Access: identity, not keys

Server · sysadmin
sudo tailscale up --hostname=ubuntu
sudo tailscale set --ssh --auto-update

With --ssh, tailscaled handles traffic arriving on port 22 from the tailnet, and the access decision moves from authorized_keys to the tailnet policy. The question isn’t “which key” but “which identity”. There are no SSH keys to hand out to clients or to lose. --auto-update, meanwhile, isn’t a convenience but a security control: the moment I hand the access decision over to tailscaled, its vulnerabilities become mine. I don’t leave patching up to my memory.

There’s one thing I deliberately stay away from: making the agent’s user an --operator. An operator can run tailscale serve and funnel without sudo, so even without sudo the agent could expose a port on the server to the tailnet, or even to the internet.

Network: a directional policy

The policy says not only who can reach what, but also which user you can log in as on the server, and it tests itself:

Admin console · tailnet policy
{
  "tagOwners": { "tag:dev-server": ["[email protected]"] },
  "grants": [{ "src": ["[email protected]"], "dst": ["tag:dev-server"], "ip": ["tcp:22"] }],
  "tests": [
    {
      "src": "[email protected]",
      "accept": ["tag:dev-server:22"],
      "deny": ["tag:dev-server:80", "tag:dev-server:8080"]
    }
  ],
  "ssh": [
    {
      "action": "accept",
      "src": ["[email protected]"],
      "dst": ["tag:dev-server"],
      "users": ["devuser", "sysadmin"]
    }
  ],
  "sshTests": [
    {
      "src": "[email protected]",
      "dst": ["tag:dev-server"],
      "accept": ["devuser", "sysadmin"],
      "deny": ["root"]
    }
  ]
}
  • There are no rules from the server to the clients or between clients; because the policy is stateful, return traffic for the sessions I open still gets through.
  • I listed the users one by one instead of using autogroup:nonroot, because on a tagged destination that means “any user other than root”.
  • tests and sshTests run when the policy is saved: if someone (me included) later allows port 80 or 8080, or adds root, the policy won’t save. Think of it as a pre-commit check for the config.

Key expiry is off by default on tagged nodes; I turned it on deliberately. I don’t want a stolen server key to stay valid forever.

Inside the server: two users

Tailscale SSH logs you in without asking for a password. So once a device reaches the server, what it can do there comes down entirely to which user it logs in as. That’s why:

  • devuser: Claude Code, projects, tmux. No sudo, no password; not in the docker group either (that group is effectively root).
  • sysadmin: apt, services, ufw, Tailscale. sudo only with a password.
  • root: denied from the tailnet; its only way in is the provider’s web console.

Home directories are 750: no permissions at all for “others”, and devuser isn’t in the sysadmin group. So the agent can’t read sysadmin’s files. I never run Claude Code as sysadmin.

The real trap wasn’t the new users but the ubuntu user that came with the image:

Server · the image's default user, one last time
$ sudo adduser sysadmin                        # set a password
$ sudo usermod -aG sudo sysadmin
$ sudo adduser --disabled-password devuser     # no password, not in the sudo group
$ ls -ld /home/*                               # 750 by default on Ubuntu; if not: sudo chmod 750 /home/*
drwxr-x--- 5 devuser  devuser  ... /home/devuser
drwxr-x--- 5 sysadmin sysadmin ... /home/sysadmin

$ sudo grep -rn NOPASSWD /etc/sudoers.d/
/etc/sudoers.d/90-cloud-init-users:ubuntu ALL=(ALL) NOPASSWD:ALL
Server · sysadmin, in a second window
$ sudo -v                                      # first: does sudo with a password work?
$ sudo rm /etc/sudoers.d/90-cloud-init-users
$ sudo usermod -L -s /usr/sbin/nologin ubuntu  # lock it, disable its shell
$ sudo deluser ubuntu sudo

Tailscale SSH doesn’t ask for a password, and the image doesn’t ask for one for sudo; put the two together and you get passwordless root from any device on my tailnet. After confirming in a second window that sysadmin’s password-protected sudo worked, I deleted the NOPASSWD:ALL line along with the file it lived in, then locked the default user and removed it from the sudo group. After a reboot the file had been written back, so now I override it with a zz- file, which sorts last and gets the final word.

Clients: terminal-first

Claude Code is a terminal-first tool, and most of the time I’m not writing code, I’m reading diffs. So the real question wasn’t “which editor?” but “where does the session live?” The answer is tmux. From any client on the tailnet, I connect to the server as devuser and open the session:

macOS / Fedora
ssh devuser@ubuntu
Server · devuser
tmux new -A -s claude   # attach if the session exists, create it if not
  • macOS and Fedora. Day-to-day work happens in the terminal. When I’m reviewing a large diff, I use VS Code Remote-SSH, always as devuser. I turn off one setting in VS Code: I open Settings (Cmd + , / Ctrl + ,), type Enable Agent Forwarding into the search box and untick Remote.SSH: Enable Agent Forwarding. Leave it on, and anyone on the server, the agent included, can use my SSH keys for as long as I’m connected.
  • Android and iOS. With the Tailscale app open and connected to the tailnet, I add a new host in Termius: label ubuntu, the full MagicDNS name as the address (ubuntu.tailxxxx.ts.net), username devuser. The password field stays empty; Tailscale verifies the identity, and there’s no key or password stored on the phone.

Filtering: three layers

I don’t rely on a single firewall but on layers that sit in different places:

LayerWhereWhat it does
Provider firewallEdge

Inbound, only 41641/udp; nothing else, SSH included

ufwHost

Allows what comes in on tailscale0, denies all other inbound

Tailnet ACLOn every node

Who, to which port, in which direction, as which user

Server · sysadmin, over Tailscale, with a second session open
sudo ufw default deny incoming
sudo ufw default allow outgoing   # ← outbound is open, see Trade-offs
sudo ufw allow in on tailscale0
sudo ufw allow 41641/udp          # for the direct WireGuard path
sudo ufw enable                   # last

41641/udp is open, but it’s a quiet door: it makes a direct path more likely, and because WireGuard never responds to unauthenticated packets, it stays silent in a port scan.

Device: Tailnet Lock

The policy is tied to the user and doesn’t tell devices apart: every device logged in with my account is “me” as far as the policy is concerned. If my account, or the control plane itself, is compromised, a foreign node joins the tailnet and every rule above treats it as “me” too. Every other layer in this setup rests on that one assumption; Tailnet Lock removes it.

Tailnet Lock: before and afterOn the left, no lock: the control plane approves new nodes; if the account or the control plane is compromised, a foreign node sits inside the tailnet alongside macOS, iOS, Android and ubuntu. On the right, the lock is on: the control plane only registers; Android joined the tailnet with the macOS signature, ubuntu was signed when the lock was set up but is not a signer, and iOS is the backup signer; the unsigned foreign node stays outside as "Locked out" and its peers won't talk to it.NO LOCK: the control plane approvesLOCK ON: your device approvescontrol planeregisters + approvestailnetmacOSiOSAndroidubuntuforeignlogin = trustif the account or control plane is compromiseda foreign node is incontrol planeonly registerstailnetmacOSsignsiOSbackupAndroidsignedubuntusignedforeignLocked outsignaturelogin ≠ trustonly a signer grants trustpeers won't talk to unsigned nodes
Logging in with the account is no longer enough to get into the tailnet.

With the lock on, a new node stays “Locked out” until a signing device signs it, even if it logged in with my account, and its peers won’t talk to it. The control plane now only registers; trust comes from my device.

Tailnet Lock needs at least two signers. Here’s the setup in the diagram:

  • Primary signer: a desktop that runs the CLI. macOS or Fedora Workstation, whichever is at hand. It’s the one that sets up the lock and signs new devices.
  • Backup signer: an iOS device. When the desktop isn’t with me, or if it’s lost, I can open the signing link or QR code from the console on my phone and sign there.
  • Android is only a client. It wasn’t chosen as a signer.

I deliberately gave the server no signing rights: the place where the agent runs shouldn’t be the place that approves new devices. Order matters: add the signers before the lock, then turn the lock on.

macOS / Fedora · primary signer
$ tailscale lock init …                    # the command the console generates
$ tailscale lock sign nodekey:… tlpub:…    # for every new device after the lock

What broke

1. I locked myself out

On my first attempt, I enabled the firewall without verifying the Tailscale connection: Tailscale hadn’t come up properly yet, and public SSH was already closed. I had to log in through the provider’s web console and run ufw disable.

On the network side, this has a well-known name: closing the old management path before verifying the new one. On a remote switch, I’d have left myself a way back with reload in 10; here I hadn’t. My rule now: test console access up front, keep a second session open, ufw enable last.

2. Latency: putting the boundary in the wrong place

First I tried working with the files mounted over the network. bun install went from seconds to minutes: every one of thousands of small file operations was crossing a boundary. Once the files moved to the server’s own disk, the problem went away. It’s the file system version of running a chatty protocol over a WAN.

3. The tunnel survived, the session didn’t

I’d given Claude Code a long task from Termius, and the app crashed. The moment the connection dropped, the server shut down everything running in that session, and Claude Code went with it. The WireGuard tunnel stays up even when the network changes, but the SSH connection inside it doesn’t. A resilient tunnel doesn’t mean a resilient session inside it. tmux solves this: it runs Claude independently of the SSH connection, so it keeps living on the server even if the connection drops. That failure is the real reason I went terminal-first.

4. I’d only drawn half the boundary

A new tailnet starts with a default policy that opens every device to every other device. The moment I added the server, the machine the agent runs on could also start connections to my MacBook and my other devices. I’d moved the agent off my MacBook, only to give it a way back to my MacBook.

Policy direction: before and afterOn the left, the default policy allows access in both directions between the MacBook and the server; on the right, after the ACL, only the MacBook can reach the server, and new connections from the server to the MacBook are blocked.DEFAULT: every direction openAFTER THE ACL: one wayMacBookubuntuagent hereMacBookubuntutag:dev-serverubuntu → MacBook: openonly MacBook → ubuntu :22return traffic passes statefully
Same two devices, two different policies.

The fix is to make the tailnet policy in the admin console directional: only from my identity to tag:dev-server, only tcp:22. There’s no rule that lets the server start connections to the clients; replies to the connections I open still come back. The full policy is in the architecture section.

5. I never drew the boundary inside the server

I didn’t catch this myself; a published Tailscale SSH vulnerability did. The chain, in short: a legitimate SSH session opened from an authorized device could be turned into root on the server through a flaw in the daemon, and from there into a rogue node and lateral movement inside the tailnet. The attack didn’t come from outside; it came from inside an authenticated session.

After reading the news, I looked at my own server. Everything on the network layer was in place: a directional ACL, public SSH closed, a single quiet UDP port. But there was only one user inside the server, and Claude Code was running as that user: the image’s default user with NOPASSWD:ALL, described above. In my setup, the chain’s root step didn’t even need the vulnerability.

The ACL told me who could reach the server, not what could happen once they got there. It’s like putting a server in the right VLAN and forgetting that everyone on it is an admin. That’s why I moved to two users. Today, every path to root, and from there into the tailnet, is cut by a separate layer:

PathWhat cuts it
Root through the daemon flaw

Current version + –auto-update

Root through sudo

No sudo for devuser, NOPASSWD removed

Root login from the tailnet

ssh.users + sshTests

Rogue nodeTailnet Lock; the server has no signing rights
Lateral movement in the tailnetDirectional grant

Only the first row depends on the patch. Even if the patch had never shipped, an attacker would get root on the server but stay there: a rogue node would run into Tailnet Lock, and lateral movement into the directional policy. That’s exactly what defense in depth is.

Trade-offs

What I gainedWhat it cost me

No re-authentication on every connection (accept, not check)

Anyone who gets hold of one of my devices while it’s unlocked connects without extra verification; mitigated by device locks (FileVault, a short screen lock, a passkey on the account) and sysadmin’s sudo password

I decide who joins the tailnet (Tailnet Lock)

A signing step for every new device, and for the server when its key expires

No open ports, identity-based access

Identity and policy live in Tailscale’s control plane; keeping them fully in my hands would mean Headscale and its upkeep

The session isn’t tied to my MacBook; I can pick it up from any device

If the server goes down or I’m offline, I can’t work; constant git push and a manual rsync copy reduce the risk but don’t eliminate it

The agent can use packages, git and APIs freely

Outbound from the server to the internet is open; the agent could move data out if it wanted to. Restricting egress is my next job.

What I learned

What really changed was the question itself. I started with “how do I run an AI agent safely?”, and that led me to permission lists. Once I switched to a network perspective, the question became “which segment should this workload run in?” That’s a question I’ve been answering for years.

Second: segmentation is a matter of direction. Closing inbound is only half the boundary; in a mesh, every connection is born two-way.

Third: segmentation doesn’t replace host hardening. The network boundary decides who can reach the server, the device boundary decides who can join the tailnet, and the server boundary decides what whoever gets in can do there. Skip one, and the other two are only half done.

And the most uncomfortable one: habits don’t carry over on their own when the tools change. Everything I’d do by reflex on a remote switch, I skipped on my own server, because there I didn’t think I was “doing networking”.

I didn’t give up convenience. I just drew its limits the way I’d draw a network’s: who, from where, in which direction, as which user, and with whose signature.

Sources

This post isn’t a setup guide; it’s an engineering log. The commands are here for context and will go stale as versions change. For the critical steps, refer to Tailscale’s and Claude Code’s own documentation.