Back to home

Open Thoughts

Your personal computer is not that personal

How it started

A colleague mentioned that Opus 5 had pulled context out of his local iMessages database. So I spent an evening pointing a coding agent at my own laptop to see what it could find out about me when it has full laptop access. The answer: quite a lot, and none of it required anything clever.

NOTE: I ran this with Opus 5 (high effort) using Claude Code 2.1.220 as the harness, on macOS, with Full Disk Access granted to my terminal. Every one of those variables matters. Results will differ for other model, harness, and effort combinations.

What it found

It read my iMessages and reconstructed who I talk to and about what. It went through my git history. It read the config files for Codex and a couple of other coding harnesses, which is how it ended up holding live API keys. It combed my browser history. It printed the contents of my clipboard.

The Photos library is worth dwelling on. It declined on the first ask. Then I said go ahead, and it went through the whole thing. A boundary that dissolves the first time you push on it was never a boundary. It was a speed bump with good manners.

The guardrails point at the wrong risk

The agent is not too powerful.

Three layers could stop a read, and they are different kinds of thing. There is the model's own judgment, which is alignment work. There is harness policy, which is Claude Code's permission rules. And there is OS enforcement, which is the sandbox. Judgment bends: it can be argued with, and it can be borrowed by anyone who gets a malicious sentence into the context window. Harness policy does not bend to argument, but it works by recognising commands, so it stops only what somebody thought to write down. OS enforcement is the hardest stop of the three, because it constrains the process instead of inspecting anything.

There is one more question underneath all three, and none of them touches it: what happens to data the agent read legitimately, on a task I asked for. That one is not about my machine at all, and I will come back to it.

The lower two are both configured in Claude Code, and easy to confuse. Permissions are policy: rules about which tool calls run, which are refused outright, and which stop to ask me. The sandbox is a boundary, enforced by the operating system, and it holds whether or not the model cooperates and whether or not I ever wrote a rule. They live in different places, they fail differently, and only one of them ships with anything in it. What they have in common is the risk they were built around: both are aimed at what an agent might break, not at what it might learn.

Look at what the defaults protect. Claude Code starts read-only and asks before it does anything else: it needs approval to run Bash commands that can modify your system, and it cannot write outside the folder you launched it in without asking. Reading paths outside that boundary with Read, Grep, and Glob is a different matter. Its sandboxing is real, enforced by the OS (seatbelt on macOS, bubblewrap on Linux) rather than by the model's good intentions or by a deny rule someone remembered to write, and it stops the agent from modifying files it should not touch and reaching servers it should not reach.

You can see the shape of it in the sandbox's resolved config.

The /sandbox Config tab on a clean install: a single denied read path, against roughly forty denied write paths.
The /sandbox Config tab on a clean install. One Filesystem Read Restriction above; roughly forty Write Restrictions below.

Writes are an allowlist: a short list of permitted paths, then forty-odd lines enumerating everything denied inside them. Reads are a denylist, and on a clean install that denylist has exactly one entry, ~/.claude/ide, the directory where Claude Code's editor integration leaves its connection lockfiles and the auth token that goes with them. The single read the default policy protects is the agent's own secret, not any of mine. The docs are upfront about this: the default is read access to the whole computer minus a few denied directories, and they note it still permits reading ~/.aws/credentials and ~/.ssh/.

My favourite detail: ~/.ssh appears in the write-deny list. The sandbox will stop an agent from modifying my SSH keys and let it read them.

The permission layer tells the same story. Here is the Deny tab, the rules Claude Code will always reject with no prompt, on a clean install:

The Deny tab in /permissions on a clean install: empty, no rules.
The Deny tab in /permissions on a clean install. Empty.

Nothing is rejected at the permission layer by default, not even destructive commands. Nothing is pre-approved either. The permission layer ships empty in both directions, which means it makes no decisions at all: it forwards every one of them to me, at the moment the agent wants to act. Out of the box the layer whose only job is to say no says it to nothing, and a command that would take the machine apart is one approval click away.

Which is where the design runs into what people actually want. Read-only-and-ask is a defensible default and a miserable way to work. The entire promise of an agent is that it runs for an hour while you do something else, and every prompt is a place where that stops and waits for you. Sit through forty of them and you stop reading them. So people do the rational thing: they allowlist broadly, or they switch the asking off, and they decide it once, early, thinking about how much less often they will have to hit approve and not about what is in ~/Library. The configuration a long-running agent is actually operating under is rarely the shipped default. It is the shipped default with the asking removed. And the sandbox is off in that default too, so nothing is left holding the line. Even switched on, it was never watching reads.

Reading is the unguarded path, and reading is the entire privacy threat model. Nothing gets damaged when an agent walks your ~/Library. Nothing gets deleted. There is no destructive command to intercept. The cost is measured in what someone now knows about you, and no permission prompt in the stack is denominated in that unit.

macOS is the same story one layer down. TCC gates ~/Library/Messages and the Photos library. The mechanism exists and it works. But the unit of consent is the app. Grant Full Disk Access to iTerm and everything you launch from it inherits that access, because TCC walks up the process tree and resolves permissions against the responsible process, not against the thing actually doing the reading. The OS is answering whether I trust iTerm. That was a good question in 2019. It is not the question anymore. The thing inside iTerm has its own goals, reads faster than any human, and synthesises across sources in a way no grep ever did.

A second adversary

So far the question has been what the agent decides to do. There is a second question, which is who gets to decide, and it is where the judgment layer hits its ceiling.

An agent with read access to your home directory and an outbound network connection is a general-purpose exfiltration primitive waiting for a sentence in its context window. That sentence does not have to come from you. It can come from the README in the repo you just cloned, a dependency you just installed, or a page the agent just fetched. At that point your data is not going to a provider under a retention policy you can read. It is going to whoever wrote the sentence, under no policy at all.

The model is not misbehaving. Its judgment is intact and entirely beside the point, because it is following an instruction it found rather than one it formed. No amount of good conscience closes this, and that is the whole argument for the hardest of the three: a boundary the agent cannot be talked past, because nothing in it is listening.

This also reframes the API keys. The finding is not that the model read my keys. It is that live credentials were sitting in plaintext in dotfiles and git history, where any compromised postinstall script could have taken them years ago without asking. The agent did not create that exposure. It made it legible in an afternoon.

Two paths to the same bytes

The sandbox isolates Bash subprocesses. That is its whole scope. The built-in file tools never enter it: Read, Edit, and Write are gated by the permission system instead, and Grep and Glob are tools too, not shell commands, so searching your disk never touches Bash either. The sandboxing docs state it plainly: permission rules apply to every tool, sandboxing applies to Bash commands and their children. Those two layers cover different sets of things, which leaves two routes to the same file.

The read I actually exercised was never sandboxed, and that is not luck. Claude Code's tool instructions, which ship with the harness rather than in the docs, tell the model to prefer the built-ins: always use Grep for search, never invoke grep or rg through Bash, use Read rather than cat. That is sensible on its own terms, because the tools are faster and return structured output. It also means the route the harness steers onto by default is the route with no OS boundary on it.

So on that route the only thing between an agent and your files is the permission layer, and it works by matching command strings rather than by constraining the process. That means it needs a hand-maintained list of what counts as reading a file, and a list like that has holes. The next section is me finding them.

There is no built-in credential deny list either, so only what you write down yourself is restricted, path by path, in settings.json:

{
  "permissions": {
    "deny": [
      "Read(//Users/you/Library/Messages/**)",
      "Read(//Users/you/Pictures/Photos Library.photoslibrary/**)",
      "Read(//Users/you/.ssh/**)",
      "Read(//Users/you/.aws/**)",
      "Read(//Users/you/Library/Keychains/**)",
      "Read(//Users/you/Library/Application Support/Google/Chrome/**)"
    ]
  }
}

Note the double slash: absolute paths in a Read rule need it. This is maybe fifteen minutes with the docs open, but it is fifteen minutes you have to know to spend, on a list you have to think up yourself, and the failure mode of getting it wrong is silent. Nothing tells you your Photos library was readable the whole time.

I tested it

Then I stopped theorising and ran it. I put a decoy file with a canary string outside my working directory, pointed a stock Claude Code session (Opus 5, high effort) at it, and tightened the config one layer at a time. Same target file every time.

How the agent reads it Stock defaults (no Read deny, sandbox off) Stock defaults (sandbox on) Read deny permission Hard sandbox deny rule Both
Read tool reads reads blocked (permission) reads blocked (permission)
Bash cat reads reads blocked (permission) blocked (sandbox) blocked (permission)
Bash grep reads reads blocked (permission) blocked (sandbox) blocked (permission)
Bash egrep reads reads reads (gap) blocked (sandbox) blocked (sandbox)
python3 -c "open(...)" reads reads reads (gap) blocked (sandbox) blocked (sandbox)

Decoy file outside the workspace, canary string inside. Read tool and cat checked in every column; grep, egrep, and python3 checked once a deny rule made the outcome non-trivial. The last three columns are alternatives rather than steps: each layer on its own, then the two together.

On stock defaults the file reads, sandbox off or on, Read tool or cat. Nothing in either layer refused it, because on a clean install there is nothing in either layer to refuse with. The only refusals I saw at all came from the model itself, and they were not consistent, which I come back to below.

Add a Read deny rule and the Read tool is blocked, and so is cat, because the deny extends to the file commands Claude Code recognises. egrep walks straight past, because it is not on the recognised list. python3 -c "open(...).read()" walks past too, because the read is hidden inside interpreter code that no command-string matcher can inspect.

Add sandbox.filesystem.denyRead for the same path and turn the sandbox on, and everything closes. egrep and python3 now fail with Operation not permitted, refused by the kernel at open() rather than by a string match.

The cleanest result is the sandbox-only column: no permission rule, just the sandbox read-deny. Bash cat was blocked. The Read tool read the file anyway, no prompt, because it never enters the sandbox. Two paths to the same bytes, and you need a rule on each. Neither layer alone closes the file.

Two observations from the run. The refusal is model-dependent: Sonnet 5 declined the bare out-of-workspace read on its own, while Opus 5 went straight to the permission prompt. On defaults the model's judgment is the real first line, and it is the least predictable of the three. And after a few repeated attempts to read the denied file, a safety classifier stepped in and switched the session from Opus 5 to Opus 4.8 mid-run.

Where the responsibility actually sits

The model provider

Four asks.

Zero retention by default on personal plans. Not as an enterprise line item, as the default for everyone. Consumer plans ship a training toggle that defaults to on. Leave it and your chats are retained for up to five years; turn it off and you drop to thirty days. Thirty days beats five years. It is not zero. For personal data the holding is the risk, not just the window: a copy of my messages on someone else's disk is an exposure that outlives the task that produced it, governed by a policy I did not write and that can change without me. This is the question I left hanging at the top, and it is the only ask here that helps the person who configured nothing. Judgment bends, and the other two layers only work if somebody set them up. Retention decides what it costs when none of that happened.

Ship a default read-deny list. This is the cheapest fix on this page. ~/.ssh, ~/.aws, ~/Library/Messages, the Photos library, browser profiles, Keychain: deny reads on all of it by default, on both the sandbox path and the permission path, and make widening it a deliberate act. This is also the one ask that survives the switch-it-off problem. Deny rules are evaluated before anything else and they are not prompts, so they hold in auto, in acceptEdits, and in bypassPermissions: the exact modes people turn on to stop being asked. Everything else in the stack degrades when you stop reading prompts. A deny rule does not. What it cannot do is catch a read nobody thought to list, which is how egrep and python3 got through above. Robust against the modes people actually use, weak against the command it does not recognise.

Widen the sandbox, and make configuring it survivable. Two parts. The sandbox is the only control here that does not have to recognise what it is stopping, and it only covers Bash. Read, Grep, and Glob never enter it, which is why the route the harness steers onto is the route with no OS boundary on it. Extend the boundary to the built-in file tools and the two-paths problem stops existing. Then fix the ergonomics. sandbox.credentials, the setting that sounds built for exactly this, covers cat ~/.ssh/id_rsa but not a Read of the same file, because it is scoped to sandboxed Bash commands. There is no default credential list to start from, and a mistake fails silently. A configuration nobody can hold in their head is a configuration that ships unset.

Refuse some of this outright, and keep refusing. The obvious objection is that refusal can be routed around. chat.db is a SQLite file; an agent that declines to read my iMessages can still reach the same data by querying the database behind them. That is true. It does not make refusal useless.

Most privacy failures here are quieter than that. The realistic failure is not me tricking a model into reading my messages. It is an agent deciding in the middle of an unrelated task that my messages would be useful context, being right, reading them, and telling me three tool calls later, if it tells me at all. Refusal is for that unconsidered read, which is the common case. It is not a security boundary, and I am not asking anyone to treat it as one. But not every user will enable the sandbox, identify every sensitive path, or write every deny rule correctly. That makes refusal necessary: not because it is the strongest control, but because it is still there when the stronger controls were never configured properly. It is a default, and defaults decide what happens on an ordinary afternoon. Sandboxing contains the adversary. Refusal stops the ordinary afternoon from becoming a privacy incident.

Which brings me back to Photos. It refused, then folded, because I said the magic words. I do not want a boundary that any expression of intent clears. If a model has decided a category of data is off-limits by default, the second ask should not unlock it.

The OS, which is where the hard boundary belongs

OS-level separation is the hardest stop of the three. That does not make the layers above it decoration. It makes them a different job: they decide what gets considered, this decides what is possible.

The naive version does not work. A syscall carries no record of who wanted it. A read() I typed and a read() a model decided on are the same read(), and no kernel instrumentation tells them apart once the agent has a shell running as me.

The version that works: stop trying to identify the caller and treat the agent as a different principal. Its own user, its own TCC identity, its own seatbelt profile or namespace, with a declared read scope enforced at the syscall layer, so anything outside the scope fails loudly instead of succeeding silently because I once clicked Allow on a terminal emulator.

Three things only this layer can do:

  1. It cannot be routed around. A refusal is the model's own reasoning, and a deny rule only catches what it recognises. Kernel enforcement needs neither cooperation nor recognition.
  2. The audit trail is not written by the thing being audited. An agent-generated log is a self-report. A kernel log is evidence.
  3. It does not need relitigating every model release. Model behaviour is a moving target by design. A seatbelt profile is not.

It should be one primitive, not one per vendor. I do not want Claude Code to ship a sandbox, Codex to ship a different one with different gaps, and whatever I install next year to ship none. I want the operating system to offer an agent identity the way it already offers a user account: a scope I declare once, in one place, in one syntax, that every harness plugs into and none of them reimplements. That has to be a joint effort. Apple and the Linux side have to expose the primitive and make declaring a scope something a normal person can do. The harness vendors have to adopt it rather than rebuild it badly. Neither half is worth much alone, and right now neither exists.

And the honest limit, which is why no layer here is sufficient on its own: containment is not confidentiality. Sandbox an agent to a single repo and that repo still goes over the wire to an inference endpoint. No boundary on my machine can block the one destination that defines the product. All three layers answer the same question, which is what gets read. None of them touches what happens to it afterwards: what is kept, what is trained on, what a third party can later reach. That is why the retention ask is not an optional extra.

The question I do not have an answer to

The 1980s personal computer was personal because the data stayed put. Nothing on it read itself. Everything since (cloud sync, telemetry, always-on assistants) has chipped at that, and an agent with read access finishes the job, because it is the first thing on your machine that can understand what it reads. Your disk has always held your bank statements, your 2FA recovery codes, and every photo you never deleted. What is new is that something in the room can form a coherent picture of all of it in about ninety seconds.

I do not want less capable models. I want the opposite: models smart enough that I want them working on my own data, and aligned well enough that they do not go through my messages even when I ask.

See ya in the next one.