One invariant: a run gets the overlap between what the machine's owner allows and what your app asked for, decided on the machine, so it holds even if the control plane is hostile.
resolved permissions = machine grant ∩ app request
An app can only ever ask for less than the machine already allows. It cannot escape
a root, widen a tool allowlist, raise a timeout, or upgrade a permission mode.
Why it lives on the machine#
Soba is the component this architecture asks you to trust least. It is a server
somebody else operates, and the whole proposition is that you can point your machine at
one. If the check lived there, the guarantee would be "this holds unless the control
plane is compromised", which is not a guarantee.
Deciding it on the machine makes it hold even when the control plane is hostile. A
request that asks for more than the grant allows is refused before anything spawns.
The default grant is nearly nothing#
With no ~/.soba/policy.json, a machine grants read-only tools inside
~/.soba/workspace and nothing else. Not $HOME, not the launch directory. See
The machine grant.
The same shape, applied six times#
The intersection is not one check. The same "narrow only" rule governs every
dimension a run can vary along:
| Dimension |
Machine says |
The app asks |
Result |
| Tools |
allowTools / askTools / denyTools |
the tools this run wants |
intersected; the machine's denylist is always unioned in |
| Directory |
roots, defaultCwd |
a working directory |
refused unless it resolves inside a granted root |
| Time |
maxTimeoutMs |
a timeout |
clamped down |
| Permission mode |
permissionMode |
a permission mode |
the less permissive of the two |
| Cost class |
what the machine can serve |
route.allow |
intersected, and enforced on both sides |
| Network |
the machine's own reachability; there is no grant field |
allowNetwork |
a restriction only: it narrows what a run may reach and can never grant access the machine did not already have |
Anything unrecognised narrows#
A machine and a control plane will not always be running the same version, so the rule
is that anything unrecognised makes the resolved permissions smaller, never larger.
The case that matters most is the cost class: a ceiling an app sent stays a ceiling even
when the machine does not recognise what is in it, rather than degrading into "anything
goes" and spending money an app explicitly said it would not.
bash, write, edit, notebookedit
These are never turned on by a request from your app, on any machine. A machine owner
can still grant them explicitly in their local policy. Tool names are compared
case-folded throughout, so the lowercase list above covers the Bash and Write a
policy file actually writes. The point is that a remote party cannot reach them by
asking.
Containment is layered, not a flag#
For an agent runtime the worker does not simply pass a list of permitted tools. It also
keeps ungranted built-ins out of the model's view entirely, confines file tools to the
working directory, refuses any attempt to bypass the permission layer, and ignores
the machine owner's own user, project and local settings.
That last clause is load-bearing. A run arriving over the network must not inherit the
machine owner's CLAUDE.md, hooks, skills or plugins, or a remote prompt could silently
trigger local configuration.
Widening is asked for, never taken#
There is exactly one request that can make a machine more permissive, and it
cannot complete without a person at that machine:
a broker may ask to widen; only a person at the machine may say yes.
An app may send a folder request. It comes in two shapes and neither reads
the disk: choose one carries no path at all and the machine answers by opening
the system's own folder chooser, so the only path that ever leaves is the one
somebody clicked; this one names a folder a person typed and the machine asks
about it by name. On a literal yes the worker adds the root to
~/.soba/policy.json and makes Write, Edit and Bash askable there; on anything
else it writes nothing.
Nothing lists a directory. An earlier version of this let an app ask for the
names of the directories inside one, so a dashboard could draw a picker. It is
gone: a paired app enumerating somebody's home directory to fill in a form is a
read of their disk that nobody agreed to.
approver: "remote" — the channel that asks the app — can never answer one.
An app approving its own request for more permission is the escalation this
model exists to prevent, so a machine set to remote refuses folder requests and
says so. "grants": "off" refuses them whatever the approver is.
What a yes buys is that writing may be asked about. It never becomes
automatic: every Write, Edit and Bash still stops for its own approval.
There is no bypass mode#
There is deliberately no "skip all permission checks" mode, anywhere. It is not a
setting an app can send, and it is not a flag a support conversation can talk someone
into. A thing that cannot be expressed cannot be asked for by mistake.
What is still trusted#
Honesty about the boundary:
- With an agent runtime, the resolved policy is advisory: the CLI is given the
permitted tool set and is trusted to honour it. With a model runtime the gate is
in-process and nothing has to be trusted. See Agents and models.
- The machine's owner is trusted about their own machine. Their local configuration can
declare a cost class because the only money reachable from that file is theirs.
- Soba is trusted to meter and bill honestly. Nothing here prevents a billing dispute;
it prevents a control plane escalating on a machine.