Skip to content

Permission model

reyn's permission system gates four kinds of capability: file paths, exec (argv-only process execution — #3226 renamed shell -> exec), MCP tool calls, and Python preprocessor steps. The defaults are conservative; anything beyond them must be declared by the workflow and approved by the user (or pre-approved in reyn.yaml).

Three layers, in order

Note: These three layers describe how a capability gets authorized — the grant hierarchy. A separate orthogonal model — the conjunctive restrict layers — describes how active runtime restrictions are combined at gate time. Both use the word "layers" but answer different questions; see the end of this page for the distinction.

┌──────────────────────────────┐  always allowed; nothing to declare
│  defaults (read-only project)│
└──────────────────────────────┘
             ↓ if the actor needs more
┌──────────────────────────────┐  declared in reyn.yaml `permissions:` (e.g. a
│  declared capability         │  file.write path list); prompted once at the
└──────────────────────────────┘  point of actual use, not at startup
             ↓ if you trust the project broadly
┌──────────────────────────────┐  reyn.yaml: permissions.<key>: allow
│  project-wide pre-approval   │  bypasses the prompt for that capability
└──────────────────────────────┘

Layer 1: defaults

Read/glob/grep anywhere under the project root. Write/edit/delete only under .reyn/. No shell, no MCP, no Python.

Exception — protected write paths: A small set of paths inside .reyn/ are carved out from the default write grant because a direct write would bypass an authorization or audit surface. The carve-out is deliberately narrow — a path needs it only when it lacks a downstream gate.

Protected path Backs Why carved out
.reyn/approvals.yaml The persistent approval store — only the runtime authorization flow writes here Permanent. It is the approval gate; there is no later use-gate, so a direct write would silently activate a never-approved grant on the next startup (#1199).
.reyn/index/sources.yaml Index source registry Transitional — carved out until the index write-gate is effective end-to-end (#1320: the postprocessor scope must carry a sandbox-policy source; the S3.4 part1 op-layer gate alone does not fire in the real index flow).

Protect-at-use (principle). A config-write carve-out is redundant when the capability it configures is gated downstream at use time. .reyn/config/mcp.yaml and .reyn/config/cron.yaml were therefore removed from the carve-out:

  • .reyn/config/mcp.yaml — writing it (installing a server) grants nothing on its own. Using a server still passes a per-server check at call time (require_mcp), so download + execute of the server package is gated regardless of who wrote the config. This now also covers mcp_install's own pre-commit probe (#3552): before that fix, install committed the config only AFTER a live connect + list_tools against the model/plugin-supplied server name, but that probe itself ran before require_mcp was ever consulted — a live network reach that preceded the gatekeeper, not merely a config write preceding a later gated use. The probe now calls require_mcp first (same interactive "Allow access to MCP server X?" approval + ContextualLayer narrowing a live tool call to an installed server already goes through), so nothing connects to a not-yet-approved server, whether the reach happens at install-probe time or at later use time.
  • .reyn/config/cron.yaml — registering a job goes through the standard require_file_write gate against the canonical config path; fired jobs run only under a user-launched in-process scheduler, and each fired op is itself permission-gated.

An actor that legitimately needs to write a still-protected path must declare it explicitly (e.g. permissions.file.write: [{path: ".reyn/index/sources.yaml"}] in reyn.yaml) and obtain the corresponding approval. The intended route remains the appropriate gated op handler — not direct file writes.

Residual risk. With mcp/cron at protect-at-use, a safe-mode step can now write .reyn/config/mcp.yaml / .reyn/config/cron.yaml directly via the broad .reyn/ zone. This is intentional and bounded: the write changes only inert configuration. The authority it appears to grant (an MCP server, a cron job) is not realized until the gated use path (require_mcp / scheduler + op gates) is crossed, which a config write cannot bypass. The approval store keeps its carve-out precisely because it has no such downstream gate.

Layer 2: declared capability

An actor that needs something outside the defaults is declared in reyn.yaml's permissions: block (PermissionDecl, built from permissions.file.write / file.read / mcp / tool / http.get / secret.write / env.expand lists). Declaring a path doesn't itself grant access — it just makes the runtime aware the actor may need it. The prompt fires just-in-time, at the point the path is actually accessed (not at startup):

[approval] chat_router/file.write needs:
  /tmp/output (just_path)

  [y] allow this run only
  [j] persist for this exact path + actor
  [r] persist for the parent dir (recursive) + actor
  [N] deny

Persistent choices land in .reyn/approvals.yaml keyed by <actor>/<op>/<path> (e.g. chat_router/file.write//tmp/output). Keys are actor-scoped — one actor's approval doesn't leak to another (security/permissions/permissions.py: "Approval keys are actor-scoped to prevent external-actor privilege escalation"). actor identifies the calling subsystem (e.g. chat_router for the LLM-router-driven op path, or a background caller like hooks/cron), not an individual named agent.

When no intervention bus is wired for the call (bus=None — a non-interactive context), the JIT prompt is skipped and outside-zone access is denied outright rather than left pending.

Layer 3: project-wide pre-approval

reyn.yaml can pre-grant capabilities project-wide:

permissions:
  exec: allow
  file.write: allow
  python:
    safe: allow
    unsafe: allow

(exec was renamed from shell in #3226 Phase 3 — a clean break, no alias. An existing reyn.yaml permissions.shell: key must be renamed to permissions.exec: by hand; it grants no authority once un-renamed.)

Use sparingly — allow removes the prompt entirely.

Non-interactive runs

A run with no intervention bus wired (CI, scripted automation, any context without an interactive TTY) proceeds without prompts. Approvals must be in place beforehand: either pre-approved in reyn.yaml or persisted to .reyn/approvals.yaml from a prior interactive run.

This is the same trust model: the automation doesn't get to decide what's safe; you do, in advance.

reyn.local.yaml for operator-local pre-approval

For dogfood automation, CI runs, or any non-interactive scripted use, the natural mechanism is reyn.local.yaml — a gitignored operator-personal override of reyn.yaml (layer 3 project-wide pre-approval, scoped to the local machine). Add:

permissions:
  file:
    read: allow
  python:
    safe: allow
    unsafe: allow

This grants project-wide pre-approval for the local environment without affecting committed reyn.yaml or production users. Interactive TTY runs elsewhere still see startup_guard prompts as documented.

Why actor-scoped keys

Approvals are keyed by actor, not globally. If actor A asks "can you write to /tmp/foo?", granting it doesn't grant actor B the same access.

The reason is composition safety: one actor's approved capability must not transitively unlock another actor's access — each actor has to ask for its own.

mcp_install permission

mcp_install gates adding a new MCP server to the configuration — it is distinct from permissions.mcp (which gates runtime tool calls from an already-configured server).

The permissions.mcp_install: ask | allow | deny bool axis shown in older docs is gone as of the Collapse arc Phase 5, not merely deprecated: PermissionDecl.from_dict's _LEGACY_BOOL_AXIS_KEYS handling (permissions.py:314-323) only warns on the key now — the compat shim that used to expand it into the equivalent list-axis grants was removed along with the require_* methods it called. Declaring permissions.mcp_install: <anything> today establishes no runtime authority; nothing reads the value.

mcp_install.handle (op_runtime/mcp_install.py) gates install the same way every other write-and-fetch action on the OS does — no dedicated axis:

permissions:
  file.write: [.reyn/config/mcp.yaml]                          # write the install target
  http.get:   [{host: registry.modelcontextprotocol.io}]        # fetch the server manifest
  secret.write: [<ENV_KEY>]                                     # persist any isSecret env vars

file.write and http.get each carry their own ask/allow/deny behavior (see their sections above) — there is no separate mcp_install-specific prompt copy or scope-tier merge; install approval is exactly file-write approval + fetch approval on those two targets. See the reyn.yaml "MCP install" section for the current worked examples (block all installs / allow without prompting / restrict to certain hosts) and reyn mcp for the CLI-side prompt shape.

Audit trail

Every successful install emits a mcp_server_installed event with server_id and scope. Filter with:

grep '"mcp_server_installed"' .reyn/events.jsonl

Permission Tier Model (FP-0022)

Reyn permissions operate on two axes:

Axis 1 — Usage Declaration (reyn.yaml permissions: block, parsed into a PermissionDecl): the operator declares what an actor is allowed to reach outside the defaults. An undeclared, out-of-zone op raises PermissionError (analogous to Android SecurityException when calling an API not in the manifest).

Axis 2 — Authorization (operator / user grants access): Four resolution layers in PermissionResolver._approve():

Layer Source Persistence
1 reyn.yaml permissions.<key> Static config
2 .reyn/approvals.yaml Cross-session
3 In-memory session decision Session only
4 Interactive prompt → Layer 2 or 3

Op tier classification

Tier Example ops Declaration Default Config restriction
0 ask_user not required unconditional pass not possible
1 web_search, web_fetch not required allow deny blocks
2 mcp required ask (4-layer) allow pre-approves
3 shell, file (outside the configured scope) required ✅ ask (JIT — bus≠None prompt at gate time; bus=None deny) the config value IS the scope (#3458): allow = unrestricted, deny = empty set (and no JIT ask), a path list = exactly that set

Tier 0 is "unconditional pass", not "default allow" — there is no config key that could block these ops without breaking workflow execution semantics.

web_fetch behavior (FP-0022)

Before FP-0022: Required web.fetch: allow in config; otherwise the tool was hidden from the router catalog (silently unavailable). Users who asked the agent to look something up received a refusal with no prompt — a confusing UX.

After FP-0022: Default-allow with 4-layer approval. The tool is always in the router catalog. First use triggers an interactive prompt (YES/NO/ALWAYS/NEVER). web.fetch: allow pre-approves (existing behavior preserved). web.fetch: deny blocks immediately.

web_search config restriction (FP-0022)

web_search now respects web.search: deny in reyn.yaml (raises PermissionError immediately). Default is allow — web search is read-only with no side effects, so operator deny is the only sensible restriction path. No interactive prompt is needed.

SSL configuration for web_fetch and MCP registry (FP-0022 follow-up)

reyn.yaml supports declarative SSL settings for web_fetch and MCP registry requests. This solves the corporate MITM proxy / custom PKI use case at config level without requiring ad-hoc env-var configuration.

web_fetch:
  verify_ssl: false          # bool — disable SSL verification entirely
  ca_bundle: /path/to/ca.pem # str  — custom CA bundle file path

(web_fetch: — #4174 T4, renamed from web.fetch: when web: split into web_fetch: / gateway:.)

Both fields are optional. Priority order (highest to lowest):

Priority Source Effect
1 web_fetch.ca_bundle set Pass the path to httpx verify=<path> (custom CA)
2 web_fetch.verify_ssl: false Disable SSL verification (verify=False)
3 web_fetch.verify_ssl: true Force SSL verification (verify=True)
4 Neither set (default) SSL_VERIFY env var → litellm.ssl_verifySSL_CERT_FILETrue

ca_bundle takes precedence over verify_ssl when both are set. The existing SSL_VERIFY / SSL_CERT_FILE env-var behavior is unchanged when neither field is configured — there is no regression for environments that already use env vars.

Common use cases:

  • Corporate MITM proxy with internal CA: set ca_bundle: /etc/ssl/certs/corp-ca.pem
  • Internal dev environment with self-signed certs: set verify_ssl: false
  • Enforce verification regardless of env vars: set verify_ssl: true

Permission is an OS I/O primitive

The permission system is part of the OS runtime, not a separate layer above it. Every side-effect performed by reyn — whether from workflow code, op handler, or any other OS-internal path — goes through the same permission resolver against the calling workflow's PermissionDecl. There is no inside/outside split: the OS uses the permission system as its core abstraction for all I/O.

Concretely, op_runtime/mcp_install.py writing .reyn/config/mcp.yaml routes through reyn.api.safe.file.write — the same gate a workflow-level safe-mode python step would use. The PermissionDecl in scope is the workflow's; the OS honors it uniformly regardless of where the call originates. The older "OS gates its callers, not itself" framing is dissolved by this: a single uniform mechanism, no cyclic concern.

Declaration axis taxonomy

Each side-effect kind has a corresponding declarable axis. The axis vocabulary is small, and bool axes are reserved for truly capability-shaped operations — those not reducible to a single file / network / secret I/O scope.

Axes

Axis Type Granularity Gate site Notes
file.read list[{path, scope}] | allow | deny per-path require_file_read() scope ∈ {just_path, recursive}. The value is the readable set (#3458): unset = the schema default <zone-root>, deny = empty set, a list = exactly that set (it replaces the default), allow = unrestricted. Resolved by permissions/file_scope.resolve_file_scope — the same function the router tool advertisement reads, so what the model is told matches what the gate enforces. Outside the set: JIT ask (bus≠None) or deny (bus=None); deny suppresses the ask too.
file.write list[{path, scope}] | allow | deny per-path require_file_write() covers write / edit / delete. Same three forms and the same resolution as file.read (#3458); its schema default is the narrower <zone-root>/.reyn symbol, minus the protected carve-outs — narrowness now visible in the config rather than only in a docstring.
http.get list[{host}] per-host require_http_get() specific host = startup prompt + silent runtime; "*" wildcard = per-host runtime prompt. Covers both reyn.api.safe.http.* (workflow-internal, specific only) and web_fetch (LLM-driven, accepts wildcard)
secret.write list[<key>] per-key require_secret_write() per-key for ~/.reyn/secrets.env; "*" wildcard for runtime-determined keys (= the per-value prompt is the actual gate)
env.expand list[<name>] per-name is_env_expand_allowed() #3198: read-side counterpart of secret.write — gates ${env:VAR} skill-load expansion (reyn.plugins.skill_load). A DISTINCT CapabilityAxis.ENV_EXPAND, not CapabilityAxis.ENV (that one gates SandboxPolicy.env_deny_names — which names are WITHHELD from a sandboxed subprocess, #3901 PR-B ④ renamed from env_passthrough and inverted from an allow-list to a deny-list — a different capability from reading a name INTO the LLM's context). Deny-by-default: empty/unset expands nothing. "*" wildcard is accepted for shape-parity with secret.write, but is strictly more dangerous here: secret.write's wildcard is backstopped by a per-value operator prompt at the actual write; env.expand's wildcard has no prompt at all — it unconditionally exposes every process env var to the LLM's plain-text context. Declare specific names; do not use "*" (see skills.md). NON-raising (unlike most require_* gates) — a denied name leaves its ${env:VAR} token unexpanded in the read result rather than failing the whole read.
mcp list[str] per-server implicit at MCP call per-server-name allowlist
python list[{module, function, mode, timeout}] per-step require_python_step() mode ∈ {safe, unsafe}
tool list[str] per-tool require_tool() named-tool allowlist
shell (no live gate) Doc drift, flagged not fixed here: this row historically named require_shell() as the gate site for a bool permissions.shell axis. require_shell() does not exist in the current codebase — the subprocess-exec gate it once named was retired when the raw shell op was removed (#1352-A/#1352-L3); subprocess access is bounded today by SandboxPolicy.deny_subprocess (#3901 PR-B ④ renamed from allow_subprocess, inverted sense; declared per sandbox config, not per permissions: dict entry) at the sandboxed_exec seam. See Why shell is the only bool below for the now-stale rationale this row supported.
allowed_mcp list[str] \| None ACL filter implicit at MCP call per-agent restriction, cross-cuts mcp

One source for the file path sets (#3458)

"Which paths are readable / writable" is answered in exactly one place: reyn.security.permissions.file_scope.resolve_file_scope(config, axis, zone_root=…). The runtime gates (require_file_read / require_file_write / is_read_allowed / is_write_allowed, all via AgentLayer) and the advertisement side (the router file-tool catalog and the system prompt's ## Files section, via PermissionResolver.advertised_file_permissions()) both call it; a subsystem with no resolver can call the free function directly.

Two properties make that structural rather than a convention kept by hand:

  • The default lives in the schema, not in a gate. FILE_SCOPE_SCHEMA declares each axis's default, so a reader of the configuration sees the default set. Previously the default zone lived inside the gate, invisible to any caller that did not build the gate — so an unconfigured project had the gate saying "the project root is readable" while the advertisement said "nothing is permitted", and the model was never told about a capability it had (#3449).
  • The default is a symbol, not a literal. The zone anchor is supplied by the entry point (ws_base_dir under chat/web, project_root under pipe / plugin / registry bootstrap, the in-container repo root under a container backend), so its value is unknown at config-load time. The schema therefore carries a typed marker (ZoneRoot / ZoneStateDir, a discriminated union — not a bare "project_root" string, which would silently mis-resolve the day the zone becomes a different concept) and the one resolution function turns it into paths for the environment at hand.

The just-in-time layer is unaffected: the configured scope is the standing set, and JIT is how a single access extends it (ask when a bus is present, deny when not).

A deliberately non-declarable gate: plugin git run-code trust

One gate is intentionally absent from the axis table above: require_plugin_git_run_code_trust (ADR 0064 §3.10, the {kind: "git"} branch of plugin_install). It has no declarable axis, no config key, and no persisted approval — by design. Installing a git-sourced plugin FETCHES remote code and then RUNS it (an MCP server / pipeline / skill registered to run in future sessions), an RCE trust boundary distinct from the fetch axis (http.get). If this decision were declarable or persistable, a single ALWAYS / reyn.yaml grant would become a standing silent-RCE authorisation for every future git plugin — and worse, an http.get approval (per-host, persistent, web.fetch-shared) could be mistaken for authority to run code from that host. So the run-code gate is a per-install, never-persisted operator confirmation: it consults/writes no approvals map, its choice set (plugin_run_code_trust_choices) offers only yes/no (structurally no ALWAYS), and it re-asks every install. Fail-closed: non-interactive callers deny. It is the one gate whose non-declarability is the security property — the taxonomy's declarable/persistable axes are exactly what it must not be.

A deliberately ungated read: builtin + registered-plugin skill/pipeline bodies

file.read's schema default above is the <zone-root> symbol (CWD/project_root in a host run); a builtin skill/pipeline's shipped body (reyn.builtin.registry's BUILTIN_SKILLS/BUILTIN_PIPELINES path entries) and an installed plugin's skills/**/pipelines/** content (~/.reyn/plugins/<name>/, ADR 0064 §3.3) both resolve OUTSIDE that zone in every deploy — the package ships outside any given project, and the plugin cache is a per-operator global directory, not project-scoped. The unmodified out-of-zone gate would hard-deny both non-interactively (there is no operator present to approve in a headless/CI run), so reyn.builtin.docs.read_builtin_body_bytes (#2913/#2914) and its mirror reyn.plugins.body_read.read_plugin_body_bytes short-circuit require_file_read for exactly this content, inside the file op handler (reyn.core.op_runtime.file.handle) — every other path, builtin-package or plugin-cache alike, still falls through to the unmodified scope gate (file_scope's <zone-root> resolution).

The plugin bypass's trust boundary is install-registration, deliberately NOT the presence of a .reyn-plugin/ marker: a marker is trivially hand-plantable at ~/.reyn/plugins/<name>/.reyn-plugin/ with no install ever having run, so keying the bypass off marker presence would let anyone read (and, worse, have an agent load as instructions) unreviewed content. Instead the check is reyn.core.op_runtime.plugin_install.is_registered_plugin_root — true only once plugin_install has reached its completion step (source-resolve → manifest-validate → operator-permission-gated global-copy write → capability-register all succeeded).

This is weaker than the builtin boundary, not equivalent to it, and that gap is deliberate: the builtin case gets its guarantee for free from importlib.resources only ever resolving to content the wheel itself ships — nothing reachable at runtime can plant a fake entry there. The plugin case's guarantee instead comes from a completion-sidecar FILE on disk, which anyone with write access to ~/.reyn/plugins/ (the same capability plugin_install's own copy-write gate requires) could in principle forge. It is accepted anyway because that same attacker already has direct write access to an already-registered plugin's skills/** content — they could edit SKILL.md directly and reach the identical LLM-visible outcome without forging anything, so the bypass does not widen the attack surface even though its trust boundary is a weaker mechanism than the builtin one. Scope is least-privilege on both sides: only skills//pipelines/ content bypasses (a .py module inside the builtin package, or a plugin's scripts//requirements.txt/mcp.json, still hits the normal gate), and ~/.reyn/plugins/.staging/ (git-clone staging — content that predates even the {kind: "git"} run-code trust gate) is explicitly excluded. Enable/disable (skills.yaml/pipelines.yaml) never gates this bypass — it toggles USE of already-approved content, not a re-review of it.

Why shell is the only bool

Historical section — see the axis-table note above. This section describes the RETIRED bool shell declaration axis and its gate require_shell(), neither of which exists in the current codebase (confirmed: grep -rn "require_shell" over src/ returns zero hits). It predates, and is unrelated to, #3226's removal of the LLM-facing shell pipeline tool (a different mechanism — thin sugar over sandboxed_exec that built /bin/sh -c <command>, never itself declarable via this axis system). Kept here as a historical record of the original bool-vs-list-axis reasoning rather than rewritten, since the criterion below (paragraph "The criterion is...") remains a fair general principle even though its one cited example is now stale.

shell was process exec of an arbitrary command. The side-effect set is unbounded (= a shell command can read any file, write any file, network any host) and the author cannot enumerate which side effects a particular invocation will produce. There is no single I/O scope to reduce it to — process exec is the irreducible primitive.

Every other former bool axis (mcp_install, mcp_drop_server, cron_register, index_drop) has been re-expressed as one or more list axes, because each is actually reducible to a small set of file / network / secret operations:

Former bool axis Equivalent list-axis decomposition
mcp_install: true file.write: [.reyn/config/mcp.yaml] + http.get: [{host: registry.modelcontextprotocol.io}] + secret.write: [<env_key>]
mcp_drop_server: true file.write: [.reyn/config/mcp.yaml]
cron_register: true file.write: [.reyn/config/cron.yaml]
index_drop: true file.write: [.reyn/index/sources.yaml] + delete on .reyn/index/<source>/index.db

The criterion is: if a capability reduces to a finite I/O scope (file path / host / secret key), use a list axis; otherwise use bool. Currently the only irreducible primitive is shell.

What was lost in the collapse, and what wasn't

Bool axes carried a per-instance approval surface (= mcp_install:<server_id> keyed per server). After collapse:

  • MCP per-server granularity is preserved at call time via the existing permissions.mcp: [<server>] axis. Installing a server (= writing .reyn/config/mcp.yaml) becomes a one-step grant; using a specific server still requires the call-time per-server check, so download + execute of the server's package still passes a per-server gate.
  • Cron per-job granularity is reduced to "may write .reyn/config/cron.yaml at all", but cron-fired workflows still go through their own runtime permission gates when they execute. The granularity reduction does not bypass downstream protections.
  • Index per-source granularity is reduced — there is no equivalent post-write gate. Drop is destructive and the per-source distinction was operator-UX, not security; the reduction is accepted.

allowed_mcp is an ACL filter, not a capability

allowed_mcp doesn't grant capability — it restricts which subset of an already-granted mcp server list a specific agent may use. ACL filters cross-cut capability axes.

Trust boundary layers

The execution surfaces that perform side-effects, ordered by enforcement strength:

┌──────────────────────────────────────────────────────────────────┐  ← STRONGEST
│  sandboxed_exec op (FP-0017)                                     │
│    OS-kernel enforcement (Seatbelt / Landlock / Seccomp)         │
│    argv-scoped, network-scoped, fs-scoped per-call               │
├──────────────────────────────────────────────────────────────────┤
│  python step (always safe; FP-0042)                             │
│    AST validation (= rejects `import os` at compile-time)        │
│    + reyn.api.safe.* honor-system path checks at function call   │
│    NOT kernel-sandboxed; subprocess runs with full user UID      │
├──────────────────────────────────────────────────────────────────┤
│  reyn package internal code (op handlers, registry client)       │
│    Uses the same `reyn.api.safe.*` primitives as skill code,     │
│    against the calling skill's PermissionDecl                    │
└──────────────────────────────────────────────────────────────────┘
  • Top (sandboxed_exec) is the only layer with OS-kernel enforcement. argv / network / fs scope is declarative per call and enforced by the platform sandbox.
  • Internal OS code uses the same reyn.api.safe.* primitives as workflow code, against the calling workflow's PermissionDecl. There is no inside/outside split — the OS exercises its own permission mechanism uniformly.
  • Python steps are always safe-mode and honor-system: AST validation prevents import os, and reyn.api.safe.* checks declared paths / hosts / keys. A non-motivated author cannot accidentally bypass via normal coding patterns; a motivated author using metaprogramming still can, so the real boundary is the subprocess isolation + the permission gate on the run_op / reyn.api.safe.* surfaces. There is no unsandboxed mode: a mode: unsafe declaration is rejected at load. A step that genuinely needs raw host access splits that I/O into a run_op.

Sandbox scoping model (sandboxed_exec)

The sandboxed_exec policy (SandboxPolicy) is scoped per axis. write is the one axis that stays closed by default — every other axis defaults to full compat (owner ruling B, #3901 PR-B ④): the sandbox's job is bounding what happens behind a permitted action, not re-deciding what the launching shell could already do. See Protect credentials from sandboxed commands for what compat-by-default means for a command that can read a secret.

Axis Policy Rationale
write (allow_write_paths) tight workspace-allowlist, closed by default The one axis an operator cannot know in advance (a workspace floor value) and therefore cannot express as permission — bounds what a process can persist.
network open by default (owner decision, 2026-06-05, generalised to full compat by #3901) A sandboxed process reaches the network the same way the launching shell can, unless network: false is set explicitly.
exec (subprocess) open by default Child-process spawning is allowed unless subprocess: false is set explicitly (enforced on Linux via seccomp, macOS via Seatbelt).
env (deny_env_names, or allow_env_names to switch to allow-list semantics) open by default The whole environment passes through, same trust level as the launching shell, unless specific names are denied (or, under allow_env_names, unless a name isn't on the list).
read broad-allow by default + optional sensitive deny-list, empty by default Unchanged since #1199 — the strict read-allowlist was abolished; deny_read_paths is opt-in defense-in-depth, not a default protection.

Why broad read was originally safe, and why that argument no longer carries the whole weight. The original #1199 design reasoned that a broad read surface is safe because the network gate — not the read surface — is the exfiltration control: with network closed by default, a process could read widely but not send anything out. #3901's full-compat ruling removed that premise: network (and now env, and subprocess) default OPEN, not closed, so "can read but can't send" no longer holds by default. The read/write asymmetry itself is unchanged (a broad read surface also removes the system-path enumeration every binary needs just to load, matching Codex's broad-read/network-off-on-Linux precedent and Claude Code's read-restriction-as-secondary posture) — what changed is that reyn no longer promises the exfiltration gate is closed by default; protecting a credential a sandboxed command can read is now the operator's own responsibility, exercised explicitly (network: false, deny_env_names, deny_read_paths), the same posture #3901's owner ruling applies to every non-write axis.

Defense-in-depth deny-list, opt-in. deny_read_paths (empty by default since #3901; previously defaulted to OS-level credential stores — ~/.ssh, ~/.aws, ~/.gnupg, …) carves sensitive locations out of the broad read surface when an operator sets it. It is not a default protection — see Configure the sandbox to set it explicitly.

Residual risk (backend asymmetry). The deny-list is enforceable only where the backend can express a deny-after-allow rule:

  • Seatbelt (macOS / SBPL) — last-match-wins, so a broad (allow file-read*) followed by (deny file-read* …) enforces the deny-list.
  • Landlock (Linux) — allowlist-only (path-beneath grants; you cannot carve a subpath out of an allowed parent), so the deny-list is not enforceable; broad read is a single read rule on /. On Linux a compromised in-sandbox process can therefore read the sensitive paths the deny-list names — but it stays bounded by the network gate (no exfiltration) and the write / exec guards. The deny-list is defense-in-depth, not the primary boundary; the primary boundary (write-allowlist + network-off) holds identically on both backends.

Industry comparison

Platform Declaration shape Runtime ask Granularity Enforcement
iOS (TCC + Entitlements) Info.plist capability + purpose string First-use prompt Capability axis OS kernel + signed entitlements
Android (≥ M) AndroidManifest.xml uses-permission First-use prompt for "dangerous" tier Permission class + scoped storage OS kernel + per-app UID
Web Permissions API Per-feature query Per-permission prompt Origin-scoped (= per-domain capability) Browser sandbox
Anthropic Claude Code Tool list (Bash / Edit / Read / Write) None at default; sandbox-mode optional Tool name (no path scope) Seatbelt (sandbox-mode) or trust
MCP servers Server-side tool list exposed to client Server owns its boundary Per-tool, server-defined Process boundary
Reyn permissions: block (list-axis dominant; one bool: shell) startup_guard + interactive on first use per-path / per-host / per-server (resource scope) AST + reyn.api.safe.* honor-system for safe-mode; kernel for sandboxed_exec

Reyn deviates from the iOS / Android "capability + first-use prompt" pattern on two axes:

  1. Granularity is finer than industry default — list-axis path / host / server scope is closer to Web's origin-scope than to iOS / Android's capability axis. The justification is that Reyn workflows are purpose-specific code (= author knows the inventory), whereas iOS / Android apps are general-purpose.
  2. Enforcement is honor-system for safe-mode python — iOS / Android rely on kernel boundaries; Reyn relies on AST validation + path / host / key checks via the reyn.api.safe.* primitives. The trade-off is implementation simplicity (= no per-step seatbelt setup) for weaker enforcement.

Collapse arc (#571)

The axis taxonomy above is the target state. The permissions audit identified that the prior design carried four bool axes (mcp_install, mcp_drop_server, cron_register, index_drop) which were redundant with file.write — the side effects all reduced to a canonical .reyn/*.yaml write reachable through reyn.api.safe.file.write, so the bool axes were duplicating coverage rather than gating new capability. The collapse arc removes them in stages:

Phase Scope Status
1 This doc — articulate "permission is an OS I/O primitive" and the collapse map this PR
2 Route op_runtime handlers (= mcp_install / mcp_drop_server / cron_register / index_drop) through reyn.api.safe.file.write; loader compat shim accepts both bool form and explicit list form follow-up PR
3 Introduce http.get: [{host}] axis (= gates reyn.api.safe.http.* per-host) and secret.write: [<key>] axis (= gates ~/.reyn/secrets.env writes per-key) follow-up
4 Migrate stdlib workflows to explicit list-axis form follow-up
5 Remove bool axes (mcp_install etc.) and require_mcp_install / require_cron_register / require_index_drop / require_mcp_drop_server from the OS surface follow-up

During Phases 1–4 the bool form (= mcp_install: true) is accepted as a compat shim that implicitly expands to the equivalent list-axis decomposition. The bool form is removed in Phase 5.

Phase 7 — prompt-timing model unification + safe.http/web_fetch collapse

Phase 7 finishes the alignment by giving the http.get axis the same prompt model as file.write:

  • Specific declared host (http.get: [{host: "api.github.com"}]) — startup_guard prompts the operator once per <skill, host> and persists the decision to approvals.yaml under <skill>/http.get/<host>. Runtime is then silent. Mirrors file.write for paths outside the default zone.
  • Wildcard (http.get: [{host: "*"}] or ["*"]) — host set is unknown at write-time (= LLM picks at runtime, e.g. web_fetch follow-up of web_search results), so the prompt fires at the actual host gate inside require_http_get. Same <skill>/http.get/<host> persistence; ALWAYS / NEVER choices apply per host.
  • No declaration — legacy web.fetch compat path with a DeprecationWarning until the segmented-migration window closes; existing workflows that relied on Tier-1 default-allow keep working.

The web_fetch op handler routes through require_http_get instead of the legacy require_web_fetch; the chat router's PermissionDecl declares http.get: [{host: "*"}] so LLM-driven fetches go through the wildcard branch. The reyn.api.safe.http subprocess path strips wildcard entries at the preprocessor — sync subprocesses can't prompt, so wildcard-host fetches must go through the async web_fetch op route.

This unifies the two HTTP surfaces (safe.http workflow-internal + web_fetch LLM-driven) under one axis with one prompt model. It matches the browser-extension host_permissions (= declared, install-time prompt) + Web Permissions API (= runtime per-feature prompt) hybrid — see the Industry comparison section.

Aspect Pre-Phase-7 Post-Phase-7
safe.http workflow-internal per-host decl, silent runtime, no prompt unchanged for specific decl; wildcard rejected (= subprocess can't prompt)
web_fetch LLM-driven Tier-1 default-allow, 4-layer per-URL prompt routed through http.get axis; chat router decl carries wildcard so behaviour is preserved
Operator prompt granularity per-URL (web.fetch key) per-host (<skill>/http.get/<host> key) — ALWAYS covers all URLs on that host
Workflow author control over LLM fetch scope none declare specific http.get hosts to constrain (= LLM can only fetch declared hosts; wildcard absent = no fallback)
Legacy web.fetch: allow / deny config direct gate honored as backward-compat alias inside require_http_get during the migration window

python permission and mode: safe allowlist

Python steps are always sandboxed. The python permission has one level:

Level Config key What it allows
safe python.safe: allow Steps that import only from PURE_STDLIB_ALLOWLIST — clock, entropy, pure compute, and __future__ (compiler directive). No filesystem, network, or process access.

PURE_STDLIB_ALLOWLIST is defined in src/reyn/core/kernel/_python_allowlist.py. __future__ is in the list as a compiler directive — it carries no runtime capability.

There is no unsandboxed level: a step declaring mode: unsafe is rejected at load with an actionable error. A step that needs raw host access (filesystem, network, process spawning) splits that I/O out into a run_op step — which carries its own permission gate and event-log entry.

Non-interactive auto-allow: in a non-interactive context (no intervention bus wired), safe-mode python steps are auto-allowed without a prompt. This mirrors the same non-interactive behavior already in place for other ops in CI runs.

The formal contract for mode: safe (= "ambient sources only") covers the full allowlist rationale and the refactor pattern for splitting raw I/O out into a run_op step.

Credential scoping (removed trigger point)

FP-0016 Component D introduced per-invocation credential scoping: a sub-skill, spawned via the now-removed run_skill op, would receive a ScopedSecretStore scoped to its declared required_credentials, intersected with the parent's own scope (a Confused Deputy mitigation). That trigger point is gone along with run_skill (#2104), and no other call site constructs a ScopedSecretStore today — security/secrets/store.py's ScopedSecretStore and CredentialScopeError classes still exist, but OpContext.secret_store is unconditionally None in the current runtime. There is currently no credential-scoping enforcement in effect; secret access is gated only by the secret.write declaration axis and OS-level file permissions on ~/.reyn/secrets.env.

Effective permission: conjunctive restrict model

The authorization layers above answer: "has this capability been granted?" A separate orthogonal question is: "given all active restrictions, is this capability allowed right now?" The conjunctive restrict model handles the second.

At gate time, a capability is permitted only if every active layer allows it:

effective = AgentLayer ∩ SandboxLayer ∩ ProfileLayer ∩ ContextualLayer
allows(axis, value) = all(layer.allows(axis, value) for layer in layers)

The restrict layers

Layer What it models Role
AgentLayer Skill declaration + the configured file scope (permissions.file.*, schema-defaulting to the <zone-root> symbol — #3458) + runtime approvals Grant layer
SandboxLayer Runtime sandbox caps (network, subprocess, env) — NOT paths (#3901 PR-B ③: an operator cannot know a sandbox's write-path floor, so the kernel backend consumes it directly rather than the permission ∩) Restrict-only
ProfileLayer Per-agent capability narrowing — the agent's default capability spec Restrict-only
ContextualLayer Per-session capability narrowing — delegation / topology / untrusted-auto Restrict-only

SandboxLayer, ProfileLayer, and ContextualLayer are restrict-only: they can narrow a permission, but cannot re-grant something the AgentLayer denied. This is a structural property of the conjunction (all(...)) — no layer's False can be overridden by any other layer.

One spec, two binding adapters (#2074)

The two narrowing layers are two bindings of one primitive: both read a CapabilityProfile (the single capability-narrowing spec, covering the mcp / tool axes + catalog-category visibility), separating the spec (what is narrowed) from the binding (when/how it is applied):

  • ProfileLayer — per-agent default binding. Reads the agent's AgentProfile.default_profile() (a CapabilityProfile). The operator surface stays the natural allowed_mcp key in .reyn/agents/<name>/profile.yaml; this maps onto the spec's mcp_allow axis internally.
  • ContextualLayer — per-session dynamic binding. Reads a CapabilityProfile resolved per-session from a delegation / topology role / untrusted-content auto-profile (.reyn/capability_profiles/<name>.yaml), composable (most-restrictive-wins) and subtractive-only.

Both feed the unchanged conjunctive ∩ above. A None spec, or a None axis allow-list, is unrestricted (⊤) — so an agent/session with no narrowing is byte-identical to a build without the capability spec.

How the two "layer" concepts relate

Two distinct concepts both use the word "layers" in this document. They answer different questions:

Concept Question Direction
Authorization 3 layers (grant hierarchy, top of page) How does a capability get granted? Hierarchical grant
Conjunctive restrict layers (this section) Given current runtime restrictions, is the capability allowed? Intersect — can only narrow

They operate in sequence: authorization resolution (AgentLayer) determines whether the workflow's declaration and approvals cover a capability; then the conjunctive intersection applies any active sandbox or profile restrictions. An approved capability can still be denied by SandboxLayer or ProfileLayer — grant-back is forbidden.

LLM spawn capability model

When an LLM uses spawn_agent or create_topology to build an org at runtime, the resulting agents and topology members operate under a ⊆-parent capability model: every spawned agent's effective capability is capped at a subset of its spawner's, recursively, with no path to escalate via spawn.

How the cap is enforced

The OS, not the LLM, sets the spawn lineage. When spawn_agent creates a new agent, the registry records parent=<spawner> from the calling context — the LLM never supplies this link (forge-guard). At gate time, the spawned agent's ContextualLayer composes the spawner's live resolved effective capability as a restrict-only conjunct:

child_effective ⊆ parent_effective   (structural, by construction)

Because ContextualLayer is restrict-only (it feeds the all(...) conjunction — see conjunctive restrict model), the child cannot exceed the parent on any axis. This holds recursively: a grandchild is capped at ⊆ the child, which is itself ⊆ the parent.

The default-deny _delegate floor also applies to spawned agents: an unbound spawned agent receives the least-privilege _delegate profile unless a create_topology binding explicitly re-grants within the ⊆-parent envelope.

No-escalation-via-spawn: the closed class

Five specific escalation avenues are closed by construction:

Escalation avenue Closed by
Live spawn (new agent exceeds spawner) ContextualLayer parent-conjunct at gate time
Rewind drop (lineage lost, constraint lifted) Lineage is WAL-tracked; rewind reconstruction restores the parent link
Absent parent (parent purged, constraint lifted) Absent-parent path fails closed — gate treats missing lineage as deny
Name reuse (new agent reuses purged name, fresh identity) Identity-keyed lineage: the OS key is not the name but an internal ID; a re-used name cannot inherit the prior agent's purged lineage
Absent/malformed bound profile (a create_topology member's declared capability_profile file is deleted or corrupted, constraint lifted) Same fail-closed philosophy generalised to the profile-binding path: a declared-but-unresolvable binding composes the restrictive _delegate floor rather than silently skipping the narrowing. The discriminator is existence-of-declaration — a member with no binding at all stays genuinely unrestricted; only a binding that names a profile that can no longer be read fails closed.

create_topology profiles stay inside the envelope

When create_topology assigns a capability_profile to a member, the profile is a further narrowing within the ⊆-parent envelope — it can only restrict, never re-grant. Because every member of a topology must already be in the creator's spawn subtree (subtree-restriction gate), the profile binding is safe by construction: it can at most reach the envelope the lineage conjunct already established.

Operator bounds on spawn tree size

The ⊆-parent model governs what a spawned agent can do. Separately, safety.spawn.max_depth and safety.spawn.max_children govern how many agents an LLM may spawn — DoS guards so an agent cannot mint an unbounded org. See reyn-yaml § safety.spawn.


What the permission system is NOT

  • Not a Linux capability sandbox. A Python step's subprocess runs as the same user; the AST allowlist is honor-system, and reyn doesn't sandbox the kernel (that layer is sandboxed_exec).
  • Not a secret keeper. Don't put credentials in approvals.yaml or rely on permissions to hide environment variables. Use Concepts: secret handling for credentials.
  • Not protection against the user. If you permissions: exec: allow in reyn.yaml, you've authorized exec. The system is protecting against accidental capability creep, not user intent.

See also