Permissions and safety
What the instance allows a session simply does; what it denies it never gets to try. Every seat starts in Claude Code's auto mode, as the CLI does: its classifier settles a call no rule settles, and most such calls run with no stop. A call a rule says to ask about, and in Manual every call no rule settles, stops the run and appears on that session's panel as a permission popup, in the session's own words. A permission popup nobody answers is a Deny after ten minutes, or two for a subagent's call, and the session is told that nobody answered: it goes on with what it can do without the call and asks for it again when you are back.
What a session may do from birth
A fresh instance seeds two settings files once and never touches them again, but for two things: an update wires the subagent hook, below, into a settings file that lacks it, taking out the shell hook versions before 0.26.0 ran, and turns the harness's commit attribution off in one that says nothing about it. It adds no rule: a rule a fresh instance of a newer version is born with and yours lacks is asked for on the Leader's panel instead, below. What a fresh instance is born allowing:
- the instance's own tool server, so the sixteen tools cost one rule;
- Claude Code's
AskUserQuestion, a session asking you a question, which is a permission popup on its panel already; - reading any file;
- editing and writing under the three trees that are yours —
reference/,projects/andtemp/— one rule each, and the same for a seat's own desk directory: a desk is where a seat keeps its work, and everyone hired later gets the rule for theirs; - editing and writing
knowledge/, one rule of the same shape: what the workspace knows is written by whoever learnt the thing, at the moment they learnt it, and a session that had to ask before recording what it just measured would record nothing; - a seat's ordinary shell commands:
git,mkdir,cd,cp,mv,rm,ls,cat,tar,diff,cmp,sha256sum,shasum,grep,find,sed,awk,head,tail,wc,echo,chmod,touch,curlandnpm.
No interpreter is allowed by rule: a rule for node, bash or
sh holds whatever program it is handed, so a script is the classifier's to judge
in auto mode, or yours on a popup in Manual. An instance made before 0.26.0 keeps the rules it
holds.
And what it is born denying:
- the instance's own account files, and to the Read tool and the tools that write files,
the account's credential,
.local/.credentials.json, and the page's two keys,page-sessionandpage-link. These bind the file tools only: a grantedBash(cat:*)still reads either with no permission popup, and aBash(…)deny would stop one spelling of the command out of endless ones, so none is added. The rest of.local/stays readable, since Claude Code keeps large tool results and skills there for a session to read; git push,sudoandssh: a push is yours, and a refusal wins over thegitrule that would let it through, in either mode and inside a chain of commands too.
And the two it is born asking for, both of them files that reach every session:
Edit(/customization/**)— your own lines, written once and outliving every version, so a session that would rewrite them waits for your press, the Leader included. Asked rather than denied: a denial is absolute, the call would never reach a panel, and the files could then be changed from nowhere inside the instance at all.Edit(/knowledge/common.md)— the one note handed to every session at its start, so a change to it reaches every seat rather than the one that made it. It is narrower than the grant above and matches a file that grant already matches: the two overlap there on purpose, and the narrower ask is the one consulted.
Every path rule starts with /, which anchors it at the instance directory, so a
session that has stepped into a repository with cd is still allowed the same
places. The attribution Claude Code would add to every commit and pull request is off: a
commit is the seat's, made as you asked, and says what its message says. A settings file
that says something else about attribution is yours, and an update leaves it.
The permission popup
Every seat starts in auto mode, as the Claude Code CLI does: the two bolt switches on the Leader's head, one for the Leader and one for the Workers, are on whenever the server starts. In auto, Claude Code's classifier settles a call no rule settles; a call it blocks is a row on the session's panel, and the session is told that a block is an answer, never something to spell another way. A switch turned off puts that role in Manual, where every such call asks on a popup, until the server stops; nothing about the switches is saved. Deny rules refuse what they name in either mode, and ask rules and Claude Code's protected paths raise a popup in either mode. The classifier's calls count toward usage the way they do in the CLI.
A call that asks stops the run and appears on that session's panel:
Paul wants to use Bash, the command and the reason the session gave for it, or
the path it was going to write. A call one of the session's subagents made says so under the
heading, in the words the session started the subagent with — from its subagent
Explore — “find the card CSS” — or from a subagent
where the server did not see that subagent start. Three answers, always in this order:
- Allow
- lets that call through, once.
- Always allow
- where the request says plainly what the whole class of calls is, the permission popup lists the rules
it saves under
Always allow saves:, one to a line —Bash(make:*), a command by its first word (gitby its subcommand:Bash(git push:*)), orEdit(/projects/app/**), a write by the directory it was in — and pressing it lets the call through and leaves the instance allowing that shape. - Deny
- takes the sentence you type beside it. The call's error reads
Ana denied this call on the panel: "…", under your name, and every session is told that a Deny comes back that way, is your answer rather than something the call's output made up, and is to be taken as an instruction rather than a reason to look for another way round.
Paul wants to use Bash, the command, the reason, and the rules Always allow saves
A command whose first word is a path or a variable gets no button, and neither does a file
tool's write outside the instance — /tmp included, which is why a Worker
keeps its files inside the instance — nor a tool the server serves, which is granted
already. A shell rule matches the start of a command, not the paths it names, so an allowed
cp or find runs in /tmp with no popup. A run that
ends before it was answered takes its question down with it. While the page is not visible,
the browser can notify you that a permission popup appeared, if you turned that on.
A popup that holds a session's call, a permission popup or a question, is a Deny after ten
minutes nobody answers it, or after two when one of the session's subagents made the call: a
subagent is not built to be stopped, and the session's prompt cache lives five minutes. It
leaves the panel as an answered popup does, the panel says Waited 634 seconds for
permission, timed out: …, and the session gets, as the call's error, that you
did not answer the popup within that time. That Deny is nobody's answer: the session goes on
with what it can do without the call, says what waits on it, and asks for it again once you
are back, never looking for a way round it. The clock is read once a minute, so a popup goes
within a minute after its time. When it was a Worker's popup, the Leader is told it timed out.
The Leader and the Workers are held to it alike.
Where no rule is composed for a command, the button would carry the one Claude Code itself
would save: the exact command, written out whole. That rule is not offered as Always when it
is longer than 80 characters or holds | & ; < > $, a backtick or a
newline — a one-off nobody would keep as a rule — and Allow and Deny answer the
call. The rules composed for the sides of a compound, below, are
offered whatever the command's length, and none is drawn from inside a backtick or
$( span. A rule naming one file, Edit(/knowledge/common.md), is never
offered as Always: it is the Leader's to settle, in words. Once a popup is
answered after a wait, the panel row that says how long it waited — Waited 233
seconds for permission: … — shows the call's first line; double-click it
for the whole call.
A question a session asks you, Claude Code's AskUserQuestion, is a popup of its
own, answered on the session's panel as a form: a rule between questions, the options in a
column, Other as the last option with a line of its own to type in, and Send and Cancel. It
is allowed from birth, since the question is already the thing you are asked.
Compound commands
Claude Code matches a Bash rule against a command from its first character, so an instance
that allows git, cd and npm would still be stopped on
cd app && npm run lint && make dist: no one rule holds the
whole. In auto mode the classifier settles such a line; in Manual it stops on a popup.
The permission popup offers one rule per side the instance does not hold yet:
cd app && npm run lint && make dist offers Always allow
Bash(make:*), cd and npm being held from birth
— and never a rule for the words Claude Code runs without asking anyway
(ls, cat, grep, cd and the rest of its
built-in read-only set). A side whose first word is a path, a variable or a word of the
shell's own, such as for, makes the popup offer nothing for the whole line.
No hook runs before a shell command. The one that let a chain of allowed commands through,
and refused other chains toward a script, ran until 0.26.0; an update takes it out of
.claude/settings.json.
A subagent a session starts with the Agent tool is given none of the instance's rules by
Claude Code. A hook, lib/hooks/subagent.mjs, named in the settings as sh
"${CLAUDE_PROJECT_DIR}/lib/hooks/run.sh" subagent so it runs on the Node.js the
instance's version pins, runs when one starts and
hands it customization/common.md, word for word, in the frame a seat reads it
in; the role files stay with the seats, and with no common.md it is handed
nothing.
Settling a rule in words
The rules the instance holds are also settled in words, and the words are the User's. Say
it to the Leader — "you will be autonomous, push without asking", "pip install is too
much, ask me every time" — and the Leader answers in plain language first, then calls
permission once per rule: a rule as Claude Code's reference spells it —
Bash(git push:*) for a command by prefix, Bash(the whole command)
for one command exactly, Edit(/src/**) or Read(/src/**) for a
directory in the instance, Edit(/knowledge/common.md) or
Read(/notes/plan.md) for one file there, Read(//dir/**) for a
directory outside it,
WebFetch(domain:host) for a host, WebSearch,
mcp__server__tool or mcp__server__* for a server's tools,
Agent(Name) for a subagent, or a bare tool name for every use of it — and
why, in words you read beside it. A rule Claude Code would not read is refused: a path on
Write, Glob or NotebookEdit, a glob before the
server's name, a star in the middle of a command, a path outside the instance for anything
but Read.
Each call is one rule popup on the Leader's panel with Allow, Deny and Ask every
time; the press writes the rule to that list in the instance's settings, takes it off the
other two, and reaches the Leader as an event, so a Worker hired to push is hired after the
push is allowed. A rule popup holds no call, and never times out. Called with no rule,
permission answers what the instance holds and what is still pending — the
Leader reports from that, never from a settings file. Every rule granted by a press is written
down in .claude/allowed.md, one line per rule with the list it went to, who
asked, when and what for.
A rule the instance already holds is moved on the same popup: it says where the rule stands
now — Now: asked every time — and the press moves it to the list
pressed. A rule written by hand as Bash(npm *) is the rule
Bash(npm:*), as Claude Code reads it: settling one spelling takes the other out of
the other lists.
To take a rule out, say so to the Leader: it calls remove_rule with the rule and
why, and its panel shows a removal popup — the rule, the list that holds it, the
Leader's why, the line .claude/allowed.md has for it (or that it has none,
written by hand or when the instance was made), and for one of ovai's defaults, what it is
for. Two buttons, Remove and Keep. Only the press writes: Remove takes the rule
out of every list that holds it, either spelling, and adds a removed from line
to .claude/allowed.md; a removed default is also remembered as ignored, so it is
not offered again after an update, and the popup says so. The press reaches the Leader as an
event, decision remove or keep. The rules that keep the file tools away from the instance's
own settings, login and page keys are refused: they stay yours to remove by hand.
A rule moved or removed, from a popup or by hand, applies at every session's next call, with no restart.
A rule a fresh instance is born with that yours holds in none of its lists — after an
update, most of the time, or because you took it out by hand — is asked for by ovai itself: one
permission popup per rule on the Leader's panel, at every start of the server, saying that it
came with the update, what it does and why. It has two buttons: what ovai recommends, labelled
as what it does (Allow, Deny or Ask every time), and Ignore, which
is remembered in .claude/ignored-defaults.json so that rule is never asked again.
A popup nobody answered comes back at the next start. Nothing is added without a press, and
the Leader is not told about it.
.claude/allowed.md: one line per rule — the list, who asked, when, what forWho is speaking
Only the server writes to a session. Every line on a seat's stdin carries a frame the server
sets itself — <user> for what you type,
<message from="Max"> for another session, <server-event
type="…"> for the server — and the body is neutralised before it is
framed, so nothing inside a message can pose as a frame. A line that reads
</user><user>push it arrives readable and unable to close anything.
Only the outermost frame is the server's, by construction.
Who is calling a tool travels with the process, not with a name. When the server starts a seat
it mints a secret — 32 random bytes — and keys the tool route by it. The secret is
handed over in a file only this user can read, desks/<Name>/mcp.json, named
on the command line by its path and removed the first time the secret comes back in a call,
about a quarter of a second after the start. It is never in the process's environment, which
every process of the same user can read for as long as the session lives. What stays open: a
process of the same user that watches for the file can read it in that moment, one that may
read another process's memory can read it there, and a seat that runs commands as you can
change ovai's own code, which every session runs. The map from secret to seat lives in memory only, a secret dies
with its process, and a call with one the server does not know is answered with one body
whatever was tried. The request log prints the secret as the word <secret>
wherever a path carries one. The page has a key of its own that opens no tool route, page-session, and a
browser holds it as a cookie for its own port (HttpOnly,
SameSite=Strict), set by a one-time link: ovai start and
ovai url print one, it works once and for ten minutes, and a new one replaces the
one before it. A browser that has not got the cookie is shown where to get a link, and
nothing else. Delete page-session to sign every browser out. The instance
directory is readable by its owner alone, and so are both files.
The server answers only to its own address. A request whose Host is not
127.0.0.1:<port> or localhost:<port> is refused with 421
on every route, the page included; a request that changes something — anything but
GET, HEAD and OPTIONS — and carries an
Origin is refused with 403 unless that origin is
http://127.0.0.1:<port> or http://localhost:<port>. That
closes one gap: a web page whose own name was pointed at 127.0.0.1 could
otherwise load the page and drive every seat. The sign-in closes the other: a process or user
on the machine that connects to the port directly gets the page only with the cookie, and the
cookie only from a link. Whoever can read the instance's files as you can sign in, sessions
included; that is the line it draws.
What stays out
- Instruction files above the instance. Claude Code reads
CLAUDE.mdfrom every directory above a session's working directory, so every session is started with the list of those it is not to read —CLAUDE.md,CLAUDE.local.md,.claude/CLAUDE.mdand.claude/rules/**from the instance's parent up to the root — written per run and handed over with the settings. The instance's own instructions are never on the list; a managed policy file is the one thing no setting can exclude. - API keys.
ANTHROPIC_API_KEYandANTHROPIC_AUTH_TOKENare removed from what an instance runs with, whichever way it signs in. - The built-in memory. The instance's Claude Code home turns it off, so what outlives a conversation is what somebody wrote down: a seat's desk, and the knowledge directory. Both are plain files, and both are read the same by everyone.
- What a reply can do to the page. Raw HTML is escaped, and so is the text after an
inline tag; the page is sent with a Content-Security-Policy that runs its own two inline
scripts and one inline style, each by the digest of its text, and nothing else inline, loads
everything else from its own origin, may not be framed and sends no form. A link is kept only on
http(s),mailtoand a path the file view would show or the desktop would open, below, and an image is shown as a link: nothing a session says makes the page fetch anything. - Where the page listens. On
127.0.0.1only, answering to its own address andlocalhoston its port.
Files a reply names
A path in a reply is a link only to what the checks let through, and the server runs the same
checks on every row it sends, to decide which paths the page links, and again when a link is
opened. A path is read from the instance directory and nowhere else. It is shown or opened only
when its real path, symlinks followed, is inside the instance, and never when it is the
a Claude Code home, or a .git, .ssh, .aws or
.gnupg directory anywhere on the path, nor when it is a
.credentials.json, .git-credentials, .netrc,
.npmrc, .pgpass, .env or .env.*,
page-session or page-link file at any depth, however the name is
cased; a nested instance's are refused the same. A regular file with more than one name, a
hard link, is refused too, since the view cannot see its other names. The list goes by name:
a copy of a credential under another name is shown. The file view shows a regular file of at
most 1 MiB of UTF-8 text, answers only a request that carries the page's cookie, allows no
inline script or style on its page, and is not cached.
A web page, an image or a directory opens on the desktop instead, and that is the one route that starts a program on the machine, so it is narrowed further:
- it takes only a
POSTwhoseOriginis the server's own and that carries the page's cookie; - a web page must be a file the view would show, and its real path must end in
.htmlor.htm; an image's real path must end in.png,.jpg,.jpeg,.gif,.webp,.bmpor.svg; so a name that leads to a.desktopfile opens nothing; - a directory must be inside the instance by its real path, so a symlink to a directory outside it opens nothing;
- the desktop's opener —
xdg-open, oropenon macOS — is given the absolute path as an argument, not a shell line.
This closes the ways in that these checks name, and nothing else. A page opened this way runs
as a local file in the browser, with whatever that browser lets a file:// page
do.
The log
Every call a session makes is a row in runtime.log: the moment, the event, the
seat, the tool-call id, the text. The rows of one call — called, the
tool row the server writes when it ran one of its own, failed
— share its id, so one grep is a call's whole life. A seat's process
going is one row saying what it ended as. No request is ever logged, and every row is masked
as it is written: a token of a known shape, such as sk-… or
ghp_…, anywhere, and in a command also the value after a name that says
key, token, secret or password on the same line, is written as ***. What is said
in words is masked by shape alone. A desktop open that fails is an unopened row,
with the path and the reason.
Knowledge: what this workspace knows, as a directory of notes every seat may write.