Caller identity and capability grants
| Field | Value |
|---|---|
| Status | Accepted |
| Authors | commixon (commixon) |
| Created | 2026-11-06 |
| Accepted | 2026-11-06, approved by the maintainer |
| Linear | COM-204; section 3.7 daemon exception COM-342 |
| Supersedes | None |
| Superseded by | None |
History
-
2026-11-08 (COM-342, normative, compatible addition; explicitly accepted by the maintainer): section 3.7 lists one daemon that may present another tool’s owner token,
venom serveto venomx, under fixed limits. Every other caller behaves as before. -
2026-11-08 (venom-org/venom#119, normative; explicitly accepted by the maintainer on 2026-11-09): section 3.8 refuses the owner token to a process below another tool’s managed tree. Until now a venomx pane could read venom’s owner file, and an agent of venom could read venomx’s. Sections 3.4 and 3.7 and the threat model follow. A human in a pane loses
@ownerand uses an issued token instead. -
2026-11-08 (venom-org/venom#122, normative; explicitly accepted by the maintainer on 2026-11-09):
grant.issuewithpidmakes a new kind of principal,process, bound to that process with no token, and a daemon keeps these across its restart. Section 3.8’s control pane uses it instead of an unbound token. Sections 2, 3.2, 3.3, 3.4, 3.8, 9, 10 and 12 follow. Tokens and every other caller behave as before. -
2026-11-08 (venom-spec#92, normative, compatible addition; explicitly accepted by the maintainer on 2026-11-09): section 3.8 says a launcher refuses below a manager, which RFC-0009 section 2 defines. The alternatives record why the marker stays a deletable file.
-
2026-11-09 (venom-spec#113, normative, compatible addition, in the PR that accepts RFC-0020 and flagged there for the maintainer): for
venom-memonly, RFC-0020 section 4 adds durable clients:tokenprincipals withreadandwriteat most, issued only withadmin, stored by the product, valid across its restarts (section 3.6), with no forced expiry (6.2), and kept out of “dies with the daemon” (section 2). Every other product andgrant.issuebehave as before. -
2026-11-13 (venom-spec#174, normative; explicitly accepted by the maintainer on 2026-11-13): new section 11.1,
grants.isolation. Each product picks its default: off for venomx, on for every other product. With it off,unknownand interactive panes holdread,writeandspawnon*, so the user’s own scripts drive the daemon with nothing set, as in tmux. A process below another tool’s manager, issued tokens and spawned children are capped as before. With it on, nothing changes. This answers Open questions 2 and 6 for venomx. Sections 2, 6.2, 11, 12 and Agent-contract impact follow, and RFC-0001 section 7 gains the exception.
Summary
This RFC defines who is calling a venom daemon and what that caller may do. It covers:
- Principals: the owner, panes, agents, bearer tokens and
unknown. - Identity resolution: on Linux,
SO_PEERPIDFD/SO_PEERCREDplus a walk of the peer’s process ancestry; a per-daemon token inhello.auth; an owner token file for the human’s own clients. - Rights: the meaning of the seven grant names that RFC-0004 already
fixes (
none,read,write,respond,files,spawn,admin). - Resource scopes, how grants are issued by attenuation, and how they expire and are revoked.
- Verbs and audit: shared
whoami,grant.*andauditverbs, the audit log, and a threat model.
It is implemented once, in venom-std’s proto module (COM-209). Every
product then supplies two things: its resource containment rules and its
per-resource checks inside handlers. Section 13 is the checklist that
COM-209’s tests follow.
Motivation
RFC-0001 section 7,
RFC-0002 section 2.4,
RFC-0003 section 3,
RFC-0004 section 2
and RFC-0006 section 9 already
require the same things. The daemon
authorizes before it runs a handler. Unknown callers get at most read-only
rights. Error class denied (exit 4) means a missing grant. hello.auth
carries credentials. Each of those RFCs leaves to this one the question of
who a caller is and which resources a grant covers. Until this RFC
exists:
- COM-209 (
protoidentity and grant checks) cannot start, and neither canvenom-echoconformance item AC-09 (COM-216). - venomx cannot tell a watcher from a writer for anyone but
unknown(RFC-0006 section 9). The P1 goal, “Claude Code agent teams run inside venomx”, needs agents that can split and drive their own panes and nothing else. - venom (P2) cannot give the agents it spawns scoped credentials (the t3code lesson in docs/ecosystem.md).
Without it, every product would either trust every same-UID caller fully, which RFC-0001 forbids, or invent its own token scheme.
Design
1. Overview by example
The human starts venomx and opens a pane from the TUI. venomx registers the pane as a principal and puts its credentials into the pane’s environment:
env | grep '^VENOMX_'
VENOMX_SOCKET=/run/user/1000/venom/venomx.sock
VENOMX_PRINCIPAL=g3
VENOMX_PANE=p3
VENOMX_TOKEN=vnt1.venomx.g3.Q2hhbmdlIG1lOiB0aGlzIGlzIG5vdCBhIHJlYWwgdG9rZW4
(The token is illustrative.) An agent started in that pane asks who it is:
$ venomx whoami --json
{"principal":"g3","kind":"pane","pane":"p3","parent":"owner","grants":[{"right":"read","scope":"*"},{"right":"write","scope":"self"},{"right":"spawn","scope":"self"}],"expires":null}
It can split its own pane. A split pane belongs to the caller’s self
scope, and the new pane gets the caller’s grants (section 6.2):
$ venomx pane split --pane p3 --json
{"pane":"p4","principal":"g4"}
It cannot type into a pane the human opened elsewhere, and it cannot change config:
$ venomx pane send --pane p1 --text 'rm -rf ~' --json
{"error":{"code":"denied.write","message":"principal g3 lacks 'write' on pane:p1","data":{"right":"write","resource":"pane:p1","principal":"g3"}}}
$ echo $?
4
$ venomx config set --key appearance.theme --value '"light"' --json
{"error":{"code":"denied.admin","message":"principal g3 lacks 'admin'","data":{"right":"admin","principal":"g3"}}}
$ echo $?
4
(pane.split and pane.send are illustrative venomx verbs.) Both denials
are appended to the audit log (section 10):
{"ts":"2026-11-06T12:00:03.000Z","source":"venomx/01234567-89ab-4cde-8fab-0123456789ab","event":"denied","principal":"g3","peer":{"pid":48211,"exe":"/usr/bin/node"},"verb":"pane.send","right":"write","resource":"pane:p1","code":"denied.write"}
A process that venomx does not know about, such as a shell in another
terminal emulator, is unknown. By default it can read but not write:
$ venomx whoami --json
{"principal":"unknown","kind":"unknown","parent":null,"grants":[{"right":"read","scope":"*"}],"expires":null}
2. Principals
A principal is an entry in one daemon’s in-memory registry:
| Member | Type | Meaning |
|---|---|---|
principal |
string | ID. owner, unknown, or g<N> allocated from a counter that only increases for the daemon’s lifetime (never reused within one source, RFC-0003) |
kind |
string | owner, unknown, pane, agent, token or process |
parent |
string or null | The principal that issued it. null only for owner and unknown |
grants |
array | Grant entries {"right":…,"scope":…} (sections 4–5), as issued |
expires |
string or null | RFC 3339 UTC time after which it is invalid, or null |
root |
(internal) | Optional bound process (pid, starttime) (section 3.3). Not exposed on the wire. |
token_hash |
(internal) | SHA-256 of the token secret, if a token was issued. Never exposed. |
label |
string | Optional human label, ≤ 64 bytes, no control characters |
pane |
string | Only for kind = pane: the pane ID |
Principal IDs match ^(owner|unknown|g[1-9][0-9]*)$, so they never contain
a colon, and principal:<id> is a valid RFC-0003 resource string.
owneris the human who runs the daemon. It holds every right on every scope (adminon*, section 4). It is fixed and cannot be revoked.unknownis any caller that resolves to no other principal. Its grants come from configgrants.unknown, defaultreadon*(section 11). It can never hold more thanread(RFC-0001 section 7); config that tries to give it more MUST failconfig check. The one exception is isolation off (section 11.1), which is a switch, not agrants.unknownlist.paneis created by a product for a process it starts in a pane (venomx). It is bound to the pane’s root process.agentis created by an orchestrator (venom) for an agent it spawns. It is bound to the agent’s root process when the orchestrator knows that PID.tokenis created bygrant.issue(section 9). It is a bearer credential that is not bound to any process.processis created bygrant.issuewithpid(section 3.3). It is bound to that process and has no token at all, so being the peer itself is the only way to it.
Principals form a tree rooted at owner through parent. The
descendants of P are its children, their children, and so on.
The registry lives in memory and dies with the daemon, except process
principals (3.3). Each daemon has its own registry. A token is valid only on the
daemon (source) that issued it.
3. Identity resolution
Identity is resolved once per connection, at accept, before the first
byte is read. RFC-0002 section 2.4 already reads peer credentials at that
point. The result is cached on the connection as the resolved principal
plus a floor (3.4). Authorization (section 7) is still evaluated on
every request, against the current registry, so a revocation takes
effect at once.
3.1 Peer process
| Platform | Mechanism |
|---|---|
| Linux ≥ 6.5 | getsockopt(SO_PEERPIDFD) gives a pidfd for the peer, and SO_PEERCRED gives its PID and UID. The pidfd is used in 3.2 to detect PID reuse. |
| Linux < 6.5 | SO_PEERCRED only. Reuse detection is weaker (3.2). doctor MUST report this as warn. |
| macOS, BSD | getpeereid gives the UID. LOCAL_PEERPID (macOS) gives the PID where the platform has it. Without a PID, the ancestry walk is skipped and the floor is OUTSIDE. |
The peer UID check from RFC-0002 section 2.4 comes first and is unchanged:
a peer with a different UID is disconnected without a reply. A peer PID of
0 (the peer is in a PID namespace the daemon cannot see) means there is
no ancestry, and the floor is OUTSIDE.
3.2 Ancestry walk
Starting at the peer PID, the daemon follows parent PIDs. On Linux it reads
fields 4 (ppid) and 22 (starttime) of /proc/<pid>/stat. On macOS it
uses proc_pidinfo(PROC_PIDTBSDINFO) (pbi_ppid, pbi_start_tvsec and
pbi_start_tvusec). At each step:
walk(peer):
pid ← peer pid; depth ← 0
loop:
st ← starttime(pid) # failure (process gone) → floor ORPHAN
if some live principal P has root == (pid, st),
and P is not kind process or depth == 0 → floor P; stop
if pid == own daemon pid → floor ORPHAN; stop
if pid == 1 or pid == 0 → floor OUTSIDE; stop
depth ← depth + 1; if depth > 64 → floor ORPHAN; stop
pid ← ppid(pid)
if the pidfd from 3.1 shows the peer has exited → floor ORPHAN
- A
processprincipal (3.3) matches only the peer itself, never an entry above it. Every other bound principal covers its subtree. - The nearest bound principal wins. A pane principal’s subtree can contain more specific bound principals (an agent inside a pane, a sub-agent inside that agent).
- A root is matched on
(pid, starttime), never on PID alone, so a dead pane’s PID reused by an unrelated process does not inherit the pane’s grants. - Subreaper. On Linux, a daemon that spawns processes (venomx, venom)
MUST call
prctl(PR_SET_CHILD_SUBREAPER, 1)atserve. A process that double-forks out of its pane is then reparented to the daemon instead of PID 1. Its walk reaches the daemon’s own PID and gives ORPHAN, so it does not look like an unrelated outside process. macOS has no subreaper, so there an escaped process looks OUTSIDE (section 12, T6). - Fail closed. If any read fails part-way through the walk, the result
is ORPHAN, which carries at most
unknown’s grants. - After the walk, if the pidfd from 3.1 reports that the peer has exited,
the floor is ORPHAN. Without a pidfd, the daemon re-reads the peer’s
starttimeand uses ORPHAN if it changed.
3.3 Binding a principal to a process
A principal is bound when the registry records its root (pid, starttime):
- venomx binds each pane principal to the process it forks for the pane.
It records the
starttimeafterforkand beforeexec. Reading/proc/<child>/statfrom the parent is enough. - An orchestrator that starts an agent through another daemon learns the
PID from that daemon’s spawn result. For example, venom calls venomx
pane.create, and the result includespid. The orchestrator MUST bind its ownagentprincipal to that PID when the PID is visible to it, and SHOULD otherwise. A bound principal cannot be borrowed by processes outside its subtree (3.4). grant.issuecreates unboundtokenprincipals (bearer tokens), unless it is givenpid.grant.issuewithpidcreates aprocessprincipal, for a process someone else started and only learned the PID of. For example, a TUI outside venomx callswindow.create, and the result includespid.
A bound principal is revoked automatically when its root process exits (for a pane, when the pane is destroyed).
process principals.
# Outside every tree, as the owner. No token comes back.
$ venom grant issue --pid=48213 --namespace=person --label="venom control pane" --json \
--grants='[{"right":"read","scope":"*"},{"right":"spawn","scope":"*"}]'
{"principal":"g9","expires":null}
# As process 48213 itself, with nothing set. Its children are `unknown`.
$ venom whoami --json
{"principal":"g9","kind":"process","parent":"owner","grants":[…],"expires":null,"label":"venom control pane","namespace":"person"}
- Who may bind:
owner, a principal withadmin, or aprocessprincipal. Anyone else getsdenied.bind, before the PID is looked at.spawnis not enough: an agent withspawncould otherwise make a principal with no expiry that survives a restart, which outlives everything else it can issue (6.2). The grants are checked as in 6.2. Aprocessprincipal may bind so that a control pane can give the panes it opens what it has, and an agent it starts is kindagent, so that agent may not. - Which process: the daemon reads
starttimewhen it handles the call. A PID that doesn’t exist isnot_found.process; the daemon’s own isinvalid_params.pid; a process that is already some live principal’s root isconflict.process_bound, so thatresolvenever has to choose. A process with a manager’s marker (3.8) isconflict.process_manager. An agent view takes its marker only after it is bound, to run its own shells, so a restored entry isn’t checked for one. - Waiting. A connection’s principal is resolved at
accept(section 3), and the process may connect before it is bound. A process that expects a binding has to reconnect untilwhoamishows it. venom’s TUI does this for at most five seconds, and then runs asunknownand says so. The wait is not a credential. - A restart. No one is left to bind again when a daemon restarts: the outside
process has long since become
venomx attach. So the daemon keeps these in the bound file,<tool>.boundnext to the socket (the suffix rule of 3.7, mode0600,O_NOFOLLOW, replaced by rename), with each one’s(pid, starttime), grants, label, namespace, expiry and parent. It survives a clean stop as well as a crash. Atserve, before the socket exists, the daemon restores an entry if its process is alive with thatstarttimeand its parent isowneror a restored entry, under a new ID (2). Principals with a cap from their creator’s floor (6.1) and any holdingadminare not kept. The file holds no secret. Other principals still die with the registry (Open question 3). A restored one is not an issue by the owner, because anyone with the UID can write the file (T16), so it says so: audit eventrestoredwith the process’s(pid, start)andexe(section 10),restored: trueinwhoamiandgrant.list, anddoctorlists theprocessprincipals asgrants.process_principals, to the owner or anadmin, who see the whole tree ingrant.listtoo.
The process alone. A process principal is the rights of that one process,
which is the difference from pane and agent. Children get nothing from it, and
resolve as if it weren’t there: unknown, with whatever manager is above them
still refusing the owner token, unless the process itself execs venom: the pid
and starttime stay, so exec keeps the rights, and fork-then-exec is unknown. This is what makes it safe to bind a person’s
client that also runs other code. venom’s agent view opens a shell or a take-over
as its own child, and a model’s Bash runs there, so these must not inherit a
person’s rights. It also limits a wrong binding: binding a PID changes what that
process resolves to and nothing below it, so a human’s shell keeps @owner in the
commands it runs.
3.4 Combining floor and token
The floor F is the outcome of the walk: a bound principal, ORPHAN or
OUTSIDE. The optional token T comes from hello.auth (3.5). They
combine like this:
| Floor | No token | Token T (not owner) |
Owner token |
|---|---|---|---|
| Bound principal P | P | T, capped by P (6.1). If T is bound, its root MUST be on the peer’s ancestry chain, otherwise denied.token (binding) |
denied.token (owner_context) |
| ORPHAN | unknown |
T, capped by unknown. A bound T fails binding (its root is not on the chain). |
denied.token (owner_context) |
| OUTSIDE | unknown |
T. A bound T fails binding. |
owner, or denied.token (owner_context) below another manager (3.8) |
To check binding, the walk continues past the nearest bound principal to
its stop condition and records the whole chain. Only the nearest bound
principal becomes the floor. The connection’s resolved principal is T if
a token was accepted, otherwise the floor (unknown for ORPHAN and
OUTSIDE). The floor stays on the connection as a cap (6.1).
Consequences, which the conformance tests check:
- A token stolen from another process (for example by reading
/proc/<pid>/environ) gives no extra rights to a process inside a managed tree. Its effective grants are capped by the thief’s own floor. - A bound token is useless outside its subtree, and a
processprincipal has no token at all (3.3). Process-bound credentials are the default in v1. Bearer tokens exist only for callers with no usable ancestry: sandboxes in another PID namespace, macOS withoutLOCAL_PEERPID, and the future remote transport. - The owner token works only from outside every managed tree, whichever tool
manages it (3.8). An agent in a pane cannot become
ownereven if it reads the owner file.
3.5 hello.auth
RFC-0002 section 6.1 left auth opaque. This RFC defines it as an object
with one optional member:
{"id":1,"verb":"hello","params":{"proto":[1],"client":{"name":"venomx","version":"0.1.0"},"auth":{"token":"vnt1.venomx.g3.Q2hhbmdlIG1lOiB0aGlzIGlzIG5vdCBhIHJlYWwgdG9rZW4"}}}
The hello result gains an optional member principal. RFC-0002 section
6.5 lets results gain optional members compatibly. principal holds the
same object that whoami returns:
{"id":1,"ok":{"proto":1,"server":{"name":"venomx","version":"0.1.0"},"caps":["cancel"],"max_frame":1048576,"principal":{"principal":"g3","kind":"pane","pane":"p3","parent":"owner","grants":[{"right":"read","scope":"*"},{"right":"write","scope":"self"},{"right":"spawn","scope":"self"}],"expires":null}}}
If the token is malformed, unknown, expired or revoked, or fails the
binding or owner-context rules in 3.4, hello MUST fail with
denied.token (exit 4), and the server closes the connection.
data.reason is one of invalid, expired, revoked, binding or
owner_context. The daemon MUST NOT quietly fall back to unknown: an
agent with a bad token has to find out. On a binary connection the error
goes in an ERROR frame on stream 0.
A connection that skips hello (the implicit hello in RFC-0002 section
6.4) has no token, so it gets the floor alone.
3.6 Tokens and the environment
Token syntax:
vnt1.<tool>.<principal>.<secret>
<tool> is the RFC-0002 tool name and <principal> is the principal ID.
<secret> is 32 bytes from the OS CSPRNG, encoded as base64url without
padding (43 characters). The prefix lets a daemon reject another tool’s
token without looking anything up, and lets a secret scanner recognise
leaked tokens. The daemon stores only SHA-256(secret) and compares it in
constant time. Tokens MUST NOT appear in logs, audit records, events,
doctor output or error data.
Environment variables follow RFC-0002’s <TOOL>_SOCKET pattern, because
one process can hold credentials for several daemons:
| Variable | Set by | Meaning |
|---|---|---|
<TOOL>_TOKEN |
The daemon that spawned the process (or the human) | Token presented in hello.auth by the generated CLI and MCP adapter. The special value @owner means “read the owner token file” (3.7). |
<TOOL>_PRINCIPAL |
The spawning daemon | Informational. Never trusted, never sent. |
VENOMX_PANE |
venomx | Informational pane ID, for scripts. Never trusted. |
For example VENOMX_TOKEN, VENOM_TOKEN and VENOM_MEM_TOKEN.
tuios uses a single VENOM_TOKEN and VENOM_PANE_ID (COM-204’s
starting point). Venom has several daemons, so the names carry the tool,
and VENOM_TOKEN is just the venom orchestrator’s variable.
The generated CLI and the mcp adapter (RFC-0004) MUST read
<TOOL>_TOKEN and present it in hello.auth. The MCP adapter runs as a
child of the agent that launched it, so its floor and token are the
agent’s own. That is how RFC-0001’s “the adapter MUST preserve the
upstream caller’s rights” is met. The adapter MUST NOT read the owner
file unless <TOOL>_TOKEN=@owner is set.
A spawning daemon MUST remove from the child’s environment every
*_TOKEN variable that the child would inherit from the daemon’s own
environment or from the requesting client. It then adds its own token. A
pane MUST NOT inherit the token of whoever created it, because the pane
gets its own principal. The exception is a token passed explicitly in the
spawn verb’s environment parameter, such as venom handing its
VENOM_TOKEN to an agent pane (6.3). Such a token is kept, and it is
still subject to the binding and floor rules.
3.7 Owner token
At serve, after taking the lock (RFC-0002 section 2.5, step 2), the
daemon writes a fresh vnt1.<tool>.owner.<secret> to the owner file
<tool>.owner, next to the socket and lock. It uses the same suffix rule
as the lock file: the socket path with .sock replaced by .owner. The
file is created with O_CREAT|O_EXCL|O_NOFOLLOW, mode 0600, after
unlinking any stale one. On clean shutdown the daemon unlinks it. A new
secret is generated on every start.
The owner token is presented by:
-
TUI presentation clients (
venomx attach,venom’s TUI). These are always driven by a human. They read the owner file by default. Keybindings are verbs sent through such a client, so they run asownerwhen the client is outside every managed tree. That keeps RFC-0001’s rule that a keybinding goes through the same authorization as the CLI. -
The CLI, only when
<TOOL>_TOKEN=@owneris set explicitly. An ordinaryvenomx …from an unmanaged shell isunknown, as RFC-0001 section 7 requires (“not merely by sharing a UID”). -
venom serve, to venomx only. RFC-0018 section 3 has it post a notification for a request left unanswered.notify.postand its dismissal needwriteonserver:root(RFC-0016 section 6), which no pane may hold, and venomx has no narrower right. The daemon stands in for the human who started it, so:- it MUST use the owner token only for one
grant.issueper token, on a connection it then closes; - that call issues
writeonserver:root, in the namespacevenom, with a TTL of at most one hour, and nothing wider; - the issued token stays in the daemon’s memory. It MUST NOT be written to disk or a log, or reach a harness, a pane or any other child;
- inside a managed tree the owner token is refused like any other (3.4), so
venom servestarted in a pane gets no token and does not post.
This is a closed list. Another daemon, or a wider token, needs its own amendment.
- it MUST use the owner token only for one
Inside a managed tree the owner token is refused (3.4, 3.8). A TUI nested in a
venomx pane therefore runs with that pane’s rights on venomx, and on any other
daemon with unknown’s rights or the token it was given.
3.8 Managed trees of other tools
The walk in 3.2 only knows the daemon’s own principals, so a process another tool manages looks OUTSIDE. Left at that, the owner rule has a hole:
# As an agent of venom. venomx does not know this process, so it is OUTSIDE there.
$ VENOMX_TOKEN=@owner venomx window create --session s0 --argv '["/bin/sh"]' --json
{"pane":"p7","window":"w3","principal":"g5"}
# In p7. venom does not know this process either.
$ VENOM_TOKEN=@owner venom whoami --json
{"principal":"owner","kind":"owner","parent":null,"grants":[{"right":"admin","scope":"*"}],"expires":null}
Rule. A daemon MUST refuse its owner token, with denied.token
(owner_context), to a peer that has a manager above it, as well as in the
cases of 3.4. Nothing else about the caller changes: with no token it is
unknown, and a non-owner token works as for OUTSIDE (3.4). This applies to
both directions of the chain above: the agent is refused venomx’s owner token, and
a pane’s process is refused venom’s.
Managers. A manager is a process that starts other processes for people or agents:
- every daemon that sets the subreaper (3.2), which today is venomx and venom;
- a presentation client that runs shells or harnesses as its own children, which today is venom’s TUI in own mode. It holds the marker while it runs in that mode, so its splits are treated as a pane’s process is. It MUST also set the subreaper before it starts any child. Otherwise a background job whose parent shell exits reparents out of the marked chain. Like a daemon, it waits only for the pids it started.
venom serve MUST set the subreaper like any other daemon that spawns (3.2).
Otherwise a double fork leaves an agent’s descendant OUTSIDE on every daemon.
A supervisor of daemons is not a manager, even with a subreaper, and holds
no marker. It starts daemons, not processes for people or agents. If it held
one, a venom serve it started would be refused venomx’s owner token (3.7). The
exception covers only this: a supervisor that also runs a person’s or an agent’s
process is a manager.
Marker. While it runs, a manager keeps an empty file
manager-<pid>-<starttime>, mode 0600, in the directory of the socket it
serves or talks to. A daemon writes it before its owner file and unlinks it on
clean shutdown. At accept, the daemon looks up each entry of the peer’s
chain except the peer itself, by (pid, starttime), in its own socket’s
directory. A marker that can’t be checked, for any reason but its absence,
counts as present.
- The peer does not count.
venom serveis a manager and may present venomx’s owner token from outside every tree (3.7). Started in a pane, it has venomx above it and is refused as before. - Only a denial follows from a marker, but anyone can cause that denial. A
marker left by a crash matches no live process. A forged one is a denial of
service: any same-UID process, an agent included, can write
manager-<pid>-<starttime>for the human’s shell or for pid 1. The human’s@owneris then refused on every daemon using that directory, with onlydenied.token.doctor, run with no token, reports a marker above the caller asgrants.below_manager(warn). Deleting the file ends the denial. - The directory is the scope. Daemons under one
XDG_RUNTIME_DIRsee each other, and a throwaway daemon on a temp socket, such as a test’s in a real pane, sees only its own. Starting venomx and venom with sockets in different directories therefore turns this rule off between them. - It needs no connection between the daemons and no running venomx, so venom
stays headless. It is checked once per connection, with one
statper process in the chain. - A launcher checks too. A process below a manager can’t use the owner token, so it also MUST NOT start a daemon that would need it (RFC-0009 section 2).
The human’s ways in. A human can always use venom, in one of these ways:
| Where | How | Rights on venom |
|---|---|---|
| A terminal outside venomx | venom, or VENOM_TOKEN=@owner venom … |
owner |
| The control pane that start makes (host mode) | Nothing to do: that outside process binds a process principal to the pid window.create returns (3.3). It passes no credential |
read, write, respond, files, spawn on *; no admin |
| An agent view that control pane opens | Nothing to do: the control pane binds it the same way, under itself | The same, and it ends with the control pane |
| A pane of the human’s own | Issue a token outside, then set it there (below) | What the token says |
| A pane with nothing set | venom |
unknown: it reads, and says how to do more |
The token shows wherever it is printed, and a pane’s screen can be read by
anyone with read, so issue it outside and enter it unseen in the pane. If it
did show in a pane, clear that pane’s screen and history.
# In a terminal outside venomx (detach first if venomx is the only one you have):
$ VENOM_TOKEN=@owner venom grant issue --namespace=person --label=my-pane --json \
--grants='[{"right":"read","scope":"*"},{"right":"write","scope":"*"},{"right":"respond","scope":"*"},{"right":"files","scope":"*"},{"right":"spawn","scope":"*"}]'
{"principal":"g9","token":"vnt1.venom.g9.…","expires":"…"}
# In the pane: paste it at the silent prompt.
$ read -rs VENOM_TOKEN; export VENOM_TOKEN
$ venom
Namespace person. On venom, a token or process principal the owner issues
with --namespace=person stands for the human. An agent it starts is issued under owner, with no expiry,
after the same coverage check. Without this a control pane’s agents would end with
it. Any other token’s expiry still bounds its agents (6.2). Only the owner,
an admin or a holder of a person token can give this namespace (RFC-0016 section
5.1). A holder’s child token is still covered by what the holder has.
What the human loses. Deny by default wins here:
VENOM_TOKEN=@ownerand a nested TUI’s automatic owner rights no longer work in a pane or a split, on any daemon.VENOMX_TOKEN=@ownerno longer works in an own-mode split or for anything venom started.- No
adminin a pane by default. The control pane can’t save settings, runauditor stop the daemon. It says what to do instead:venom settingsin a terminal outside venomx. The owner can issue a pane anadmintoken, and that is a decision to trust everything that can read it. - Popups only read. A key table can’t hold a credential (RFC-0016 section
7.4) and a popup is a pane, so the inbox and settings popups connect as
unknown, and venom never learns a popup’s pid to bind it. Answering and saving happen in the control pane, or from outside. - A token issued for a pane ends. It lasts at most
grants.max_token_ttl, 24 hours by default, and dies with the daemon that issued it. The TUI says so, and the human issues another. Agents keep running. A control pane has no token, so this doesn’t apply to it: its principal lasts as long as its process. - A shell in a pane can’t configure a daemon, for the same reason an agent can’t.
What it does not stop. The rule looks at the processes above the peer at
accept, so it does not hold for a process that has none of them:
- A process that outlives its manager. Once a daemon stops, cleanly or by
kill -9, its surviving children reparent away from it, and its marker no longer matches anything above them. Such a process isowneron the other daemon at once, and on the stopped one after it restarts. The same goes for a process left behind when an own-mode TUI exits. - A process with an outside parent.
systemd-run --userfrom an agent starts a process that has no manager above it, and it gotowneron both daemons. tmux, ssh and cron-style parents are expected to behave the same, since each hands the work to a parent outside the tree. This has the same cause as reading the owner file from a second login.
Grants are not a sandbox (section 12): stopping these takes an OS sandbox that hides the owner file. Also:
- An issued token is unbound, so a process below a manager that reads it from
/proc/<pid>/environcan use it (T2, T15). A control pane has none to read. - The bound file (3.3) can be edited by any same-UID process, which then gets an
entry’s rights, short of
admin, within seconds, since it can restart the daemon itself (T16). - Removing marker files is outside the sanctioned channel, like reading another process’s memory (section 12).
- macOS has no subreaper, so a process that escapes its tree there also escapes this rule (T6).
4. Rights
RFC-0004 fixes the right names. This RFC fixes what they mean. Every right
except none and admin is held on a scope (section 5).
| Right | Allows | Examples |
|---|---|---|
none |
Nothing. Marks a verb that needs no grant. | ping, whoami |
read |
Observing state in scope: queries, events, pane contents, redacted config, skill, doctor |
events, pane.read, RFC-0006 attach as a watcher |
write |
Changing the product’s own state in scope: creating, changing or destroying its resources, sending input | pane.send, pane.kill, RFC-0006 INPUT |
respond |
Answering approvals or questions raised by other principals (an inbox) | Approving a tool call for an agent |
files |
Reading or writing host files outside the product’s own state, within path scopes | skill.install, file transfer, worktree creation |
spawn |
Starting processes and creating principals (always attenuated, section 6) | Running a command in a new pane, starting an agent, grant.issue |
admin |
Everything, on every scope. This includes config.set, config.reload, audit, revoking any principal, and daemon shutdown. |
owner, a principal the owner explicitly made admin |
Implications. These are the only ones, and an implementation MUST compute them exactly:
- Any of
write,respond,filesorspawnon scope S impliesreadon S. You cannot sensibly change what you cannot see. adminimplies every right on*.- Nothing else implies anything. In particular,
writedoes not implyspawn, andspawndoes not implywrite.
respond and self-approval. A principal MUST NOT respond to an item
raised by itself or by any of its descendants. The check is
responder ∉ {raiser} ∪ descendants(raiser), so an agent cannot approve
its own request through a sub-agent it spawned. Violations fail with
denied.self_approval (exit 4). A product with an inbox MUST record the
raising principal on each item, and the raiser MUST come from the
connection’s resolved principal, never from request params. The inbox
verbs themselves belong to the product’s RFC. This rule applies to all
of them.
5. Scopes
A grant entry is {"right": <right>, "scope": <selector>}. Selectors:
| Selector | Covers |
|---|---|
* |
Every resource of the daemon, including resources created later |
<kind>:* |
Every resource of that kind, for example pane:* |
<kind>:<id> |
That resource and its descendants under the product’s containment relation, including ones created later |
self |
The caller’s own resources (below) |
path:<absolute-dir> |
Only for files: that directory and everything below it |
<kind>:<id> uses RFC-0003’s resource syntax. There are no globs other
than the two * forms above. Malformed selectors fail with
invalid_params.grant_scope.
Containment. Each product declares a containment relation in its own
RFC or spec (for venomx, session ⊃ window ⊃ pane). It does so through a
parentOf(resource) → ?resource callback that proto calls. A grant on
session:s1 covers every window and pane in s1, now and later. Unlike
RFC-0003 event selectors, grant scopes do include descendants.
RFC-0003 section 3’s rule that a resource selector does not include
descendants is about filtering, and it is unchanged.
self. Every product resource records its owning principal: the
resolved principal of the request that created it (owner for resources
created at startup or from config). self covers:
- the principal’s own pane (
panemember), if it has one; - every resource whose owner is the caller or one of its descendants;
- the descendants of those resources under containment.
self is evaluated when a request is checked, so resources created later
are included.
path: scopes. Before checking a path, the daemon resolves it with
realpath (symlinks followed, .. removed). The resolved path MUST be the
scope directory or lie under it, compared by whole path components, so
/home/u/proj does not cover /home/u/project2. A path that does not
exist yet is checked through its nearest existing ancestor and then created
with O_NOFOLLOW. This is the “destination scope” that RFC-0001 section 4
requires for skill.install. files on * covers every path.
Principals as resources. principal:<id> is a resource with
containment along the principal tree. A principal can always read its own
record and its descendants’ records. That is what grant.list returns
(section 9).
6. Issuance and attenuation
6.1 Effective grants
Grants are stored as issued and intersected at check time, per resource. A connection whose resolved principal is P, with floor F, holds right R on resource X exactly when every principal in the cap set has an issued grant that covers R on X (directly or by the implications in section 4). The cap set is:
- P, and every ancestor of P up to (not including)
owner; - F, when F is a bound principal, and every ancestor of F up to
owner; unknown, when the floor is ORPHAN.
For the dispatch gate (section 7), “holds R on some scope” means every member of the cap set has some issued grant that gives R (directly or by implication), whatever its scope.
Each principal’s self is resolved against that principal itself. For
example, a child issued write self under a parent holding write pane:* may write the resources the child owns, because each is in the
child’s self and also in pane:*. Effective rights are recomputed for
every request. Narrowing or revoking any member of the cap set therefore
narrows the connection immediately, with no need to rewrite records.
6.2 Who can issue what
New principals come from three places:
- Spawning a process-backed principal. A product verb that starts a
process under a new principal requires
spawnon the target scope. A venomx pane split requiresspawnon the session or source pane; starting a venom agent requiresspawnon the thread. Such a verb SHOULD accept an optionalgrantsparameter: an array of{right, scope}objects in RFC-0004 format 1, withrighta string with the seven choices andscopea string. If the parameter is omitted, the child gets the creator’s issued grants unchanged. Like environment inheritance, this can never escalate, because effective grants are intersected with the creator’s (6.1). If the parameter is given, each entry MUST be covered by the creator’s effective grants, otherwise the verb fails withdenied.delegation(exit 4,data: {"right":…,"scope":…}) before it spawns anything. - Interactive panes. A pane created by
ownerwith nograntsparameter (the usual case: a keybinding in the TUI) gets the config profilegrants.interactive. The default isreadon*pluswriteandspawnonself(section 11). A human who starts an agent by hand in that shell gives the agent those rights. The human can make the pane narrower or wider, up toadminif they choose. With isolation off, the profile is the fixed one in section 11.1 instead. grant.issue(section 9) creates an unboundtokenprincipal. It requiresspawn. The requested grants MUST be covered by the caller’s effective grants, as in item 1.
A requested entry {R, S} is covered when every member of the
caller’s cap set (6.1) has a grant giving R on a scope that contains S.
Containment between selectors: * contains everything; <kind>:*
contains every <kind>:<id>; <kind>:<id> contains the selectors of its
containment descendants; path:A contains path:B when B is A or lies
under A; and a principal’s self contains the new child’s self.
Anything else is not covered.
Only owner, or a principal that itself holds admin, can issue admin.
Expiry. ttl (seconds, optional) sets expires. A child’s expires
is clamped to its parent’s. Unbound token principals MUST have an
expiry: the default and maximum are config grants.max_token_ttl, default
86400 seconds (section 11). Bound principals have no expiry by default,
because they end with their root process.
6.3 Cross-daemon delegation (confused deputy)
When a daemon acts in another daemon on behalf of a caller, it MUST use
a credential no stronger than that caller’s. For example, venom performs a
venomx action because an agent asked for it through venom’s verbs. It
MUST NOT use its own, broader principal there. Concretely:
- When venom starts an agent pane through venomx, it passes
grantsto the venomx spawn verb. venomx registers the pane principal (bound to the pane’s process) and puts itsVENOMX_TOKENinto the agent’s environment. venom adds the agent’sVENOM_TOKENthrough the spawn verb’s environment parameter. - When venom later acts in venomx on the agent’s behalf, it MUST present that agent’s venomx credential. That is either a child token venom issued under the agent’s venomx principal, or the agent’s own venomx principal if the agent called venomx directly. It MUST NOT present venom’s own.
- A hook (RFC-0001 section 8) runs as a child principal of the principal whose request triggered it, with that principal’s grants.
proto provides issueChild(parent, grants, ttl) so that a daemon can
mint the per-caller credential for these cases.
6.4 Revocation
grant.revoke and automatic revocation (a bound root exits, a pane is
destroyed, a principal expires) remove the principal and all its
descendants. Afterwards:
- New requests on a connection whose resolved principal is revoked fail
with
denied.revoked(exit 4). The connection stays open, so the client can report the error. - In-flight non-streaming requests that already passed authorization complete.
- Streams MUST end within one second. Event subscriptions end with
denied.event_scope(RFC-0003 section 3). Pane streams (RFC-0006) getERRORwithdenied.readon the stream. A writer whosewritewas revoked but who keepsreadis closed withdenied.write. It may re-attach as a watcher, because RFC-0006 fixes the role at attach. - The audit log records each revocation, with the number of cascaded descendants.
Narrowing a principal’s grants in place is not supported in v1. To narrow, revoke and re-issue.
7. Authorization at dispatch
Every request goes through two checks:
- Dispatch gate (in
proto, before the handler). Let R be the verb’s schemagrant(RFC-0004). If R isnone, allow. Otherwise the caller’s effective grants MUST contain R on some scope, or R must be implied (section 4). If not, replydenied.<R>without calling the handler. Verbs whose right is not scoped (admin) are fully decided here. - Resource check (in the handler, through
proto). Before acting on or revealing resource X, the handler callsauthorize(conn, R, X). That call MUST happen before the handler looks X up, so a caller without rights getsdenied.<R>whether or not X exists. Existence of a resource is never revealed to a caller without at leastreadon it (as RFC-0003 section 3 already requires for events). After it passes, an absent X gives the usualnot_found.<kind>. Containment cannot be resolved for an absent X, so an absent X is covered only by*,<kind>:*, or a selector naming X itself. Any other caller getsdenied.<R>.
A handler that lists or streams many resources MUST filter by read on
each one. It never fails the whole list because some items are not
visible. An explicit resource list in params is authorized in full before
any output, as RFC-0003 section 3 already says for events.
denied.<right> error data:
{"right":"write","resource":"pane:p1","principal":"g3"}
resource is present only when the caller named it in params. principal
is the caller’s own ID. When the caller is unknown, data.fix SHOULD
carry a runnable hint that does not reveal any secret. For example:
{"right":"write","resource":"pane:p1","principal":"unknown","fix":"venomx whoami --json # run inside a venomx pane, or set VENOMX_TOKEN"}
Error codes added by this RFC (all exit 4 unless noted):
| Code | When |
|---|---|
denied.read, denied.write, denied.respond, denied.files, denied.spawn, denied.admin |
Missing right (dispatch gate or resource check) |
denied.token |
hello.auth rejected; data.reason as in 3.5 |
denied.revoked |
The connection’s principal was revoked or expired after hello |
denied.delegation |
Requested child grants exceed the caller’s effective grants |
denied.self_approval |
respond on an item raised by the caller or one of its descendants |
invalid_params.grant_scope (exit 2) |
Malformed scope selector; data.param names the field |
not_found.principal (exit 3) |
grant.revoke or grant.list on an absent principal the caller may read |
RFC-0006’s denied.read and denied.write, and RFC-0003’s
denied.event_scope, are used unchanged.
8. Interaction with existing RFCs
- RFC-0001 section 2 table: unchanged.
skill.installholdsfilesscoped to the resolved destination viapath:. - RFC-0002:
hello.authis defined (3.5).hello,cancelandpingstay ungated. A connection’s principal does not change afterhello. - RFC-0003: “caller scope” means the
readscopes of the effective grants, evaluated live. Revocation ends subscriptions (6.4). - RFC-0004: a verb’s
grantmember is the dispatch-gate right (section 7). Generated docs andskillMUST explain the read-only default and thatdenied.*exits 4.skillMUST NOT mention the owner file or@owner. Agents are not meant to elevate themselves, and the owner path is for humans (it is documented in the man page). - RFC-0006: a writer is an attachment whose effective grants include
writeon the pane atATTACHtime.
9. Shared verbs
The grants bundle is merged into every daemon’s schema, like the RFC-0001 standard bundle:
| Verb / CLI | Right | Params | Result |
|---|---|---|---|
whoami |
none |
— | Principal object (3.5) for the caller |
grant.list / grant list |
read |
optional string principal (default: caller) |
{"principals":[…]}: that principal and its descendants that the caller may read. owner and admin see the whole tree from owner. |
grant.issue / grant issue |
spawn |
required grants (array of {right, scope}); optional integer ttl; optional string label; optional integer pid (3.3) |
{"principal":"g9","token":"vnt1.…","expires":"…"}. The token is returned once. With pid: {"principal":"g9","expires":null}, no token, and expires only if ttl was given. |
grant.revoke / grant revoke |
spawn |
required string principal |
{"revoked":["g9","g12"]} (the principal and cascaded descendants) |
audit |
admin |
optional integer limit (1–1000, default 100) |
{"records":[…]}, newest last (section 10) |
grant.revokeis allowed on the caller’s own descendants withspawn, and on any principal exceptownerandunknownwithadmin. Revoking yourself is allowed, and the connection then getsdenied.revoked.- In the
whoamiprincipal object,grantsis the caller’s grants as issued. When the caller is capped by something besides its own ancestors (6.1), the optional membercapped_bylists the floor’s principal ID (orunknownfor ORPHAN). Clients MUST NOT assumegrantsalone is the full answer whencapped_byis present. - A session issuing a short-lived token for a sandboxed tool:
$ venomx grant issue --grants '[{"right":"read","scope":"session:s1"}]' --ttl 600 --label sandbox --json
{"principal":"g9","token":"vnt1.venomx.g9.c2FuZGJveCB0b2tlbiwgaWxsdXN0cmF0aXZlIG9ubHkh","expires":"2026-11-06T12:10:00Z"}
$ venomx grant revoke --principal g9 --json
{"revoked":["g9"]}
Events (RFC-0003 catalog additions; resource principal:<id>):
| Type | Data |
|---|---|
principal.created |
kind, parent, grants, expires, optional pane |
principal.revoked |
reason: revoked, expired or exited; cascade (integer) |
principal.denied |
verb, right, optional resource |
These are visible through the principal-resource rules in section 5: to
the principal itself, its ancestors, and admin. principal.denied lets
an orchestrator or the TUI show “agent g7 tried to write pane p1”. It is
the documented hook through which RFC-0001 section 7 allows approval UX.
It never blocks the request, which has already failed. Events MUST NOT
contain tokens or request params.
10. Audit log
Location: $XDG_STATE_HOME/venom/<tool>/audit.ndjson, or
~/.local/state/venom/<tool>/audit.ndjson if XDG_STATE_HOME is unset or
empty. The directory is 0700 and the file 0600, with the same lstat
checks as RFC-0002 section 2.4. Override: config grants.audit.path. The
log is in the state directory, not the runtime directory, so it survives a
reboot.
One compact JSON object per line, appended with O_APPEND. Records MUST be
written for:
event |
When |
|---|---|
denied |
Any denied.* reply, including hello token failures and the dispatch gate |
admin |
Every request whose dispatch-gate right is admin, allowed or not |
owner_auth |
Every connection that resolves to owner |
issued / revoked |
Every principal creation and revocation, including automatic ones |
restored |
A process principal read back from the bound file at serve (3.3). peer names that process, and has start. principal is the parent in the file, which proves nothing about who wrote it |
Fields: ts, source, event, principal, peer (pid, and exe
from /proc/<pid>/exe or proc_pidpath, for attribution only), verb,
right, optional resource, optional code, optional target (the
principal issued or revoked), optional cascade. Records MUST NOT contain
tokens, secrets or request params other than the resource named.
Rotation: when the file exceeds grants.audit.max_bytes (default
16 MiB), it is renamed to audit.ndjson.1, shifting older files up, and
the daemon keeps grants.audit.keep (default 2) old files. If a write
fails, the daemon continues, and doctor reports fail for check
grants.audit_writable. Authorization never depends on the audit log
succeeding: a failed write neither allows nor denies anything extra.
audit (section 9) returns the most recent records from the current file.
11. Configuration
The shared grants section, validated by cfg (RFC-0001 section 5):
.{
.grants = .{
.unknown = .{
.{ .right = .read, .scope = "*" },
},
.interactive = .{
.{ .right = .read, .scope = "*" },
.{ .right = .write, .scope = "self" },
.{ .right = .spawn, .scope = "self" },
},
.max_token_ttl = 86400,
.audit = .{ .path = "", .max_bytes = 16777216, .keep = 2 },
.isolation = true, // the product's default; venomx: false (11.1)
},
}
unknownmay contain onlyreadentries, or be empty (.{}), which meansunknowngets nothing beyondnoneverbs. Anything else fails withinvalid_paramsinconfig check/config set.grants.*changes apply onconfig reload. Changes tounknownandinteractivetake effect for subsequent requests on existing connections; principals already issued keep their issued grants.- Changing
grantsrequiresadmin(it goes throughconfig.set).
11.1 Isolation
grants.isolation (bool) says whether the daemon keeps its principals apart.
The product sets the default and documents it: venomx defaults to off,
because a terminal multiplexer is mostly used by one person whose own
scripts should just work, as they do in tmux. Every other product defaults to
on.
On is the model the rest of this RFC describes, unchanged.
Off trusts the UID:
unknown(OUTSIDE and ORPHAN floors, no token) holdsread,writeandspawnon*. So does the interactive profile (6.2 item 2).respond,filesandadminare not included: approvals, host files,config setandauditstill need the owner or an issued principal.grants.unknownandgrants.interactiveare not used. They keep their validation, so turning isolation on brings them back as written.doctorreportsgrants.isolation(infowhen off) and warns when either list differs from its default while isolation is off.- Below another manager, as before. A peer with no token that has another
tool’s manager on its chain (3.8) is capped by
grants.unknownas if isolation were on. An agent that venom starts outside venomx panes therefore gains nothing from this switch. - Attenuation is unchanged. A token is exactly as narrow as it was
issued, a principal is capped by its floor (3.4, 6.1), and a pane created
with a
grantsparameter, or by a principal other thanowner, gets what 6.2 item 1 says. Isolation off only widens the two defaults. - Namespaces (RFC-0016 section 5.1) become names only: see RFC-0016.
- The setting applies on
config reloadto later requests on existing connections, like the two lists. Changing it requiresadmin.
A product that runs agents on top of a venomx daemon (venom, venom-os) SHOULD
turn isolation on there, or warn in doctor when it is off. With it off, an
agent that escapes its pane’s tree resolves to ORPHAN and gets the wider
unknown.
12. Threat model
Assets: the PTYs, sessions and state of each daemon; the ability to
spawn processes and write files on the host; approvals (respond); and
the owner role.
Trust boundary. Every caller has the daemon’s UID (RFC-0002 section
2.4). Grants are not a sandbox. A same-UID process that deliberately
attacks the user can already read ~/.ssh, ptrace (subject to Yama),
edit the config file, or read the owner file. Grants protect the
sanctioned channel, meaning verbs over the socket, against:
- agents that are confused, prompt-injected or overreaching, while they use that channel as documented;
- escalation between principals that the daemon itself created;
- mistakes, by making every denial visible and attributable.
Strong isolation of an agent needs an OS sandbox (Landlock, bubblewrap or seatbelt) that hides the owner file and other users’ state. That is out of scope for v1, but this design does not prevent it: the owner token lives in a file, process-bound credentials use ancestry, and bearer tokens cover callers in another PID namespace.
| # | Threat | Mitigation | Residual |
|---|---|---|---|
| T1 | Malicious pane writes to another pane, or kills it | write scoped to self by default (6.2, 11); resource check before lookup (7) |
None within the channel while isolation is on. With isolation off (11.1), every pane, and every same-UID process outside venomx, may write anywhere. |
| T2 | Pane steals another pane’s token from /proc/<pid>/environ |
Bound tokens are useless outside their subtree, and effective grants are capped by the thief’s floor (3.4) | An OUTSIDE process can use a stolen unbound token until it expires (max_token_ttl) |
| T3 | Pane spoofs VENOMX_PANE or VENOMX_PRINCIPAL |
These are informational and never trusted (3.6) | None |
| T4 | Agent in a pane reads the owner file and presents it | owner_context refusal inside managed trees (3.4), its own daemon’s or another tool’s (3.8) |
Sockets in different directories, and processes with no manager above them (3.8) |
| T5 | PID reuse: the peer exits and its PID is recycled into a privileged pane | Identity resolved at accept; pidfd liveness check; (pid, starttime) root matching (3.2) |
On Linux < 6.5, a narrow race between accept and the walk; doctor warns |
| T6 | Escaping the tree (double fork, setsid) to look OUTSIDE |
Subreaper: escapees resolve to ORPHAN, which has at most unknown rights (3.2) |
On macOS, escapees look OUTSIDE (unknown unless they have an owner or unbound token) |
| T7 | Confused-deputy agent: venom performs an agent’s request with venom’s broader rights | On-behalf-of rule (6.3); proto.issueChild; MCP adapter runs as the agent’s child (3.6) |
Depends on each orchestrator following 6.3; COM-216 conformance tests it for the shared paths |
| T8 | Agent approves its own permission request, directly or through a sub-agent | denied.self_approval (4) |
A colluding sibling outside the raiser’s subtree that holds respond. Do not give agents respond by default. |
| T9 | Delegating more than you have | Attenuation check at issue time and intersection at use time (6.1, 6.2) | None |
| T10 | OSC 7501 or OSC 133 claims identity or approval | Observations only (RFC-0001 section 8, RFC-0003 section 7); never an input to 3.x | None |
| T11 | Token leaks through logs, events, doctor, skill or error data |
Explicit MUST NOTs (3.6, 9, 10); vnt1. prefix for scanners |
Leaks by the holder itself (shell history, agent transcripts) |
| T12 | Path scope escaped through symlinks or .. |
realpath, component-wise prefix, O_NOFOLLOW creation (5) |
TOCTOU between check and use for directories the attacker controls; handlers SHOULD use openat from the checked directory |
| T13 | Audit-log flooding to hide activity | Size-based rotation with keep files; doctor reports rotation rate |
An attacker can push old records out; ship logs elsewhere if that matters |
| T14 | Unknown local process reads secrets in pane contents | grants.unknown is configurable down to .{} |
Default is read on * (Open question 2). With isolation off it can also write and spawn (11.1). |
| T17 | Isolation off (11.1): a confused or injected program writes to or spawns in panes it never created | Off is a deliberate, product-documented default for single-user use. admin, respond and files stay out of reach; peers below another manager, tokens and spawned children are capped as before; doctor reports the mode |
Accepted for venomx, as in tmux: the boundary is the UID. Turn isolation on when agents run in panes. |
| T15 | Chained owner files: an agent of one tool reads another tool’s owner file, opens a pane, and that pane reads the first tool’s | Refused at both links (3.8); subreaper on every spawning daemon (3.2) | A process that outlives its manager, or starts with an outside parent (systemd-run --user, and in principle tmux, ssh or cron), has no manager above it and gets owner. A process that deletes the markers, or reads an unbound token a human gave a pane from /proc/<pid>/environ. A forged marker denies the human’s @owner; doctor reports it (3.8). |
| T16 | Forged bound file: a process in a pane adds an entry to <tool>.bound for its own pid, or for another live process, and needs a restart. It need not wait: it can SIGTERM the daemon and run venom serve itself, which has no launcher check, and has those rights in about 1.5 s. Since children get nothing (3.3), it speaks on the socket itself, or execs venom on the pid it wrote |
The file is 0600, and writing it, like signalling the daemon, is outside the sanctioned channel. A restored entry never holds admin, and only that process holds it, not its children (3.3). restored in the audit log, grant.list and doctor shows that an entry came from the file, after the fact. |
Accepted. It gets read, write, respond, files, spawn on *, so it can approve for other agents. “Not its children” doesn’t limit a forger: holding the entry, it can call grant.issue --pid=<its own child>, and the child has the same rights, recorded as issued with nothing saying restored. restored doesn’t separate a forged entry either, since after any restart the real entries say it too. Only the exe and pid in the audit record do, and a forger that execs venom defeats exe. Same tamper class as deleting a manager marker (T15), which needs no restart and gives owner, so this widens nothing. The limit of venom-org/venom#122: it removes a credential readable by anyone who looks at /proc/<pid>/environ, so the threat moves from reading a variable to tampering with state on purpose. It doesn’t stop tampering. That takes an OS sandbox (section 12). |
Not covered: other UIDs and root (rejected at the socket, RFC-0002 section 2.4; Open question 4), kernel compromise, and remote peers (COM-234).
13. Implementation checklist (COM-209)
proto MUST provide, and COM-209’s tests MUST cover:
- ID-01 — Resolve at accept: peer UID check, then the pidfd (Linux ≥ 6.5) or PID, then the walk (3.2). The result is cached on the connection and never re-resolved.
- ID-02 — Ancestry mapping: a test spawns a bound root, a child and a grandchild, and checks that each resolves to the root’s principal. A nested bound principal wins over its ancestor’s.
- ID-03 — Starttime binding: a root that has exited, with its PID faked in the registry for an unrelated process, does not match.
- ID-04 — Orphan: a double-forked descendant (with subreaper set)
resolves to ORPHAN, which has at most
unknown’s grants. - ID-05 — Outside: an unrelated process resolves to
unknownwithout a token. - ID-06 — Token fallback: an OUTSIDE caller with a valid unbound
token gets the token’s grants. Malformed, unknown, expired, revoked and
other-tool tokens each give
denied.tokenwith the rightreason, and the connection closes. - ID-07 — Floor cap: a bound caller presenting another principal’s
unbound token gets the intersection. A bound token presented outside its
subtree gives
binding. - ID-08 — Owner: the owner file is created
0600withO_EXCLand a new secret on every start, and unlinked on shutdown. The owner token is accepted from OUTSIDE and givesowner_contextfrom inside a managed tree or from ORPHAN. - ID-09 — Other managers: the owner token gives
owner_contextto a peer with a marked process above it, in the socket’s directory. It does not when only the peer is marked, when the marker’sstarttimediffers, or when the marker is gone.doctorwarns for that peer. A daemon with the subreaper holds a0600marker while it runs. With no token that peer isunknown. - GR-01 — Gate before handler: a verb with
writecalled byunknownreturnsdenied.writeand the handler is not called (a counter stays 0).noneverbs pass for everyone. - GR-02 — Implications: exactly the three rules in section 4.
- GR-03 — Scopes:
*,kind:*,kind:idwith containment through a testparentOf,selfwith creator tracking, andpath:with symlink and..cases and the/projversus/project2case. - GR-04 — No existence leak: for a caller without rights, a
missing and an existing resource give the same
denied.<right>. - GR-05 — Attenuation:
grant.issuebeyond the caller’s grants givesdenied.delegation. Intersection along the chain is applied at use time after an ancestor is revoked. - GR-06 — Revocation: cascades to descendants; a later request
gives
denied.revoked; aneventsstream ends withdenied.event_scopewithin one second. - GR-07 — Expiry: a token past
expiresis rejected athelloand, on a live connection, at the next request. - GR-08 — Self-approval: the helper
canRespond(responder, raiser)rejects the raiser itself and its descendants. - AU-01 — Audit: records for denied, admin, owner_auth, issued and
revoked, with no token bytes. A test greps the log for
vnt1.and for the test secret. Rotation atmax_bytes. - AU-02 — Audit failure: an unwritable audit path leaves
authorization results unchanged and makes
doctorreportfail. - CF-01 — Config:
grants.unknownwith a non-readentry failsconfig check.
Non-normative API sketch for proto:
pub const Right = enum { none, read, write, respond, files, spawn, admin };
pub const Resource = struct { kind: []const u8, id: []const u8 };
pub const Containment = struct {
ctx: *anyopaque,
parentOf: *const fn (ctx: *anyopaque, r: Resource) ?Resource,
ownerOf: *const fn (ctx: *anyopaque, r: Resource) ?PrincipalId,
};
// Called by handlers; returns error.Denied → `denied.<right>`.
pub fn authorize(conn: *Conn, right: Right, r: Resource) error{Denied}!void;
pub fn authorizePath(conn: *Conn, right: Right, path: []const u8) error{Denied}![]const u8; // returns the realpath
pub fn issueChild(reg: *Registry, parent: PrincipalId, grants: []const Grant, ttl: ?u32, root: ?ProcRef) !Issued;
pub fn canRespond(reg: *Registry, responder: PrincipalId, raiser: PrincipalId) bool;
Agent-contract impact
- Verbs: adds the grants bundle:
whoami(none),grant.list(read),grant.issueandgrant.revoke(spawn), andaudit(admin). Spawning verbs in products SHOULD accept thegrantsparam shape from 6.2. The schema’s existinggrantmember is the dispatch-gate right. --jsonand exit codes: no new classes. Addsdenied.*details,invalid_params.grant_scopeandnot_found.principal(section 7). All denials exit 4.- Events: adds
principal.created,principal.revokedandprincipal.denied(section 9), with resourceprincipal:<id>. Revocation ends affectedeventssubscriptions. skill,doctor,config:skillexplains the read-only default andwhoami, and never the owner path.doctoradds the checksgrants.peerpidfd(warn on fallback),grants.subreaper,grants.owner_file(mode and owner),grants.audit_writableandgrants.unknown_policy(warn ifunknowncan read*) andgrants.isolation(11.1).configgains thegrantssection (section 11), which needsadminto change.- Capability grants: this RFC defines them. Unknown callers are read-only at most while isolation is on. Mutation requires an issued principal. With isolation off (11.1), the default in venomx, unknown callers and interactive panes may also write and spawn.
- Headless: no TTY is needed anywhere. Identity comes from the process
tree and tokens, never from prompts. Approval UX is events plus
respondverbs, never stdin. - OSC 7501 / OSC 133: no change. Terminal sequences never carry identity or grants (T10).
Alternatives
- Tokens only, no ancestry (pure bearer credentials in the
environment). Simpler, but any same-UID process that reads
/proc/<pid>/environtakes over a pane’s rights, and a child can drop its token by unsetting a variable but cannot drop its ancestry. Ancestry gives an unforgeable floor within the managed tree. Tokens remain for callers with no usable ancestry. - Ancestry only, no tokens (tuios-style PID mapping). Fails for sandboxed agents in their own PID namespace, for daemons that did not spawn the caller (venom versus venomx), and for remote bridges.
- Trust every same-UID caller (tmux). Explicitly forbidden by RFC-0001 section 7. It makes a prompt-injected agent equal to the human.
- Auto-present the owner token from any CLI outside a managed tree.
Convenient for the human’s other terminals, but it gives every agent that
runs outside venom full rights by default. Opting in with
@ownerkeeps that a deliberate act. See Open question 1. - cgroup-based identity (one cgroup per pane; read
/proc/<pid>/cgroup). Robust against double-fork escapes, but Linux-only, it needs systemd or cgroup delegation, and it is heavier than v1 needs. It can later be added as another floor source without changing the wire. - Macaroons or biscuits (attenuable tokens that clients can verify offline). Elegant attenuation, but every daemon is the sole verifier of its own tokens, and the registry already gives attenuation and instant revocation. Not worth a dependency in every Zig repo.
- Hierarchical rights (
admin > write > read). Too coarse:spawn,filesandrespondare independently dangerous and must be grantable separately (arespond-only approver, afiles-only installer). - Persisting principals and tokens across daemon restarts. Persistent bearer tokens are a standing liability. Durable policy belongs in config profiles, and durable sessions get fresh principals when panes are restored. Deferred (Open question 3).
- Ask venomx whether a pid is in a pane (for 3.8). Needs venomx up and reachable when venom accepts a connection, which headless venom can’t assume, and only answers for venomx. A marker needs neither.
- Look for
VENOMX_PANEin the ancestors’ environments (for 3.8). Those variables are never trusted (3.6), a double-forked process with the variable unset has none above it but the daemon, and ancestors of another UID can’t be read. - Only venomx refuses venom’s agents (for 3.8). Leaves a take-over’s model in a pane, and every other process in a pane, able to become venom owner.
- Only venom refuses panes (for 3.8). Leaves an agent as venomx owner, able
to
pane sendinto the human’s shells. - Name the process (
PR_SET_NAME) instead of a marker file (for 3.8). Can’t be removed by same-UID file access, but has no scope: every test daemon started inside a real pane would be refused its own owner token. - An environment switch that lets a human’s pane through. An agent can set it too. A human’s pane and an agent’s look the same to a daemon, and that is the whole problem.
- Issue the control pane an unbound token (for 3.8; what venom-org/venom#119
shipped). It expires, dies with the daemon, and a process below a manager can
read it from
/proc/<pid>/environ. - Have venomx say which pane is the control pane after a restart (for 3.3). Needs venomx up, which headless venom can’t assume, and a tag only the owner can set. It is also a claim by another daemon about who holds these rights.
- Have the TUI hold a secret to bind itself again (for 3.3), that is, make a restored process prove it is the one. The daemon would have to keep the hash in a file, and a same-UID process could write its own into that, so it has the weakness of the bound file and a credential too.
- Sign the bound file with the owner token (for 3.3). The owner token changes at each start, so a key would have to outlive it, in a file. And a process below a manager can read the owner file today, only presenting it is refused (3.8), so it could sign too.
- Check the restored process’s parent chain (for 3.3), for example that venomx’s daemon is above it. A process in any pane has that same chain, and this would tie venom to venomx, which headless venom can’t assume.
- Check the restored process’s executable (for 3.3). The forger can name a child
that
execs venom, orvenom mcp, whose pid and starttime stay the same, and it would be driving that from outside. - Let a
processprincipal cover its subtree, likepaneandagent(for 3.3). A shell or take-over harness below a bound agent view would hold a person’s rights, and could approve for other agents. Binding venomx’s daemon would give them to every new pane. - A marker that can’t be deleted (for 3.8), such as an abstract socket name
the manager holds (venom-spec#92). Rejected: deleting the marker is not the
weakest route, so this closes nothing an agent needs. Without touching a marker,
a same-UID process can start outside the tree with
systemd-run --user, kill its manager and outlive it, or read an unbound token from/proc/<pid>/environ(T15). An abstract name would also cost more than it gives. It exists only on Linux, so macOS still needs the file. It lives in the network namespace, so a caller inunshare -nfinds no name and looks clean, which fails open where a file fails closed (3.8). Any UID can bind one, and a forged one can’t be ended by deleting a file, only by finding and killing its holder. Looking one up costs more than the onestatper process that 3.8 promises. This threat is the OS sandbox’s (section 12), which can also make the directory read-only for the agent, with no new mechanism. Revisit it if deliberately hostile same-UID agents without a sandbox enter the threat model, and then for all these routes together. - Do nothing. Blocks COM-209, AC-09 and the P1 agent-teams goal, and
leaves
deniedwith no producer.
Open questions
All six were deferred by the maintainer at acceptance (2026-11-06). v1 targets a single local user, so the choices in this RFC stand as v1 behaviour. They are revisited together in COM-235 (Open questions & revamps), whose trigger is remote attach, multi-user use, sandboxed agents, or the defaults getting in the way in daily use.
- Owner access from the CLI. Deferred to COM-235. v1: explicit
opt-in with
<TOOL>_TOKEN=@owner(3.7). - Default for
unknown. Deferred to COM-235. v1:readon*, configurable down to.{}(section 11). For venomx, isolation off (section 11.1, venom-spec#174) answers it. - Persistent principals. Deferred to COM-235. v1: a restored pane gets a freshly issued principal.
- Other UIDs and root (RFC-0002 Open question 6). Deferred to COM-235. v1: same UID only, rejected at the socket.
- Remote peers. Deferred to COM-235 and the remote-transport RFC
(COM-234). The remote end MUST require
hello.auth(RFC-0002 section 8). v1: the owner token is never valid over a bridge. - Interactive profile defaults. Deferred to COM-235. v1:
read *,write self,spawn self(section 11). For venomx, isolation off (section 11.1) answers it.