Permissions
Role resolution, match-rule DSL, cron/subagent provenance, security guard tiers
src/permissions/ is per-actor access control. Roles bundle permission strings AND match rules. Actors are derived at runtime from SessionOrigin; nothing about an actor is stored on disk. Consumers query via ctx.permissions.has(origin, perm). Today's consumers: security plugin (every guard) and channel router (channel.respond wake-up gate).
Model
- Actor — runtime, derived from
SessionOrigin. Not stored. - Role — named bundle of
permissions[]+match[]. Declared undertypeclaw.json#roles. - Permission —
<plugin>.<verb>.<noun>string. Plugins declare viadefinePlugin({ permissions: [...] }).
Resolution walks roles in severity-then-declaration order: owner → trusted → custom (reverse declaration; later wins) → member → guest. First role with any matching rule wins. Built-in privileged roles always go first, so a broad rule on member can't shadow a narrower rule on owner. Fallback is built-in guest (empty by default, but grantable — see below).
The fail-safe floor is not the guest role; it is the undefined origin. has(undefined, …) always returns false regardless of role config, so every downstream check (bypass tiers, fs.see.*, subagent.spawn) stays closed even after an operator grants guest a permission. resolveRole/describe still report guest for audit/display; only authorization is forced closed.
Built-in roles (src/permissions/builtins.ts)
| Role | Built-in match[] (prepended) | Built-in permissions[] |
|---|---|---|
owner | [{ kind: 'tui' }] | channel.respond, session.control, session.admin, cron.schedule, cron.modify, subagent.spawn/cancel/output, subagent.spawn.operator, fs.see.private, fs.see.secrets, security.bypass.low/medium/high, plus the wildcard sentinel expanded to every plugin's security.bypass.* |
trusted | [] | channel.respond, session.control, session.admin, cron.schedule, subagent.spawn/cancel/output, subagent.spawn.operator, fs.see.private, fs.see.secrets, security.bypass.low/medium |
member | [] | channel.respond, session.control, subagent.spawn/cancel/output, fs.see.private, security.bypass.low |
guest | [] | (none, but grantable) |
User-declared match[] appends; user-declared permissions[] replaces (no merge — [] means none). Custom role names must declare both.
The role tower is monotonic on the fs.see.* axis: fsSeePrivate gates workspace/ + memory/ + sessions/, fsSeeSecrets gates .env + secrets.json. resolveHiddenPaths masks whatever the resolved role lacks, so member sees private state but not secrets, and guest sees neither.
Core permission strings
channel.respond and session.control are distinct on purpose: session.control gates the /stop command (both text-prefix and native-slash paths in the router), so a respond-capable guest granted channel.respond to drive masked turns still cannot abort other speakers' sessions. Session lifecycle control stays member-and-up.
session.admin is the operate-the-agent tier above session.control: it gates the /reload and /restart channel commands (both router paths). /reload runs the ReloadRegistry (same effect as typeclaw reload); /restart bounces the container via hostd (same effect as typeclaw restart). Because both mutate global agent state and drop every in-flight session, the default grant is owner+trusted only — a member who can /stop a single turn cannot reload config or restart the container. The router gates both the text-prefix and native-slash paths through one tier→permission map (commandPermissionString in router.ts) so a new control tier can never be honored on one path and skipped on the other.
Per-subagent spawn permission
subagent.spawn is the generic grant; subagent.spawn.<name> is a per-subagent grant. A subagent that declares requiresSpecificPermission: true (the bundled operator subagent) can be spawned only with subagent.spawn.<name> — the generic subagent.spawn is ignored for it. Every other subagent (explorer, scout, reviewer) accepts either the specific string or generic subagent.spawn. This is why granting guest plain subagent.spawn lets it spawn the read-only/research subagents but not the write-capable operator.
Match-rule DSL (src/permissions/match-rule.ts)
Compact strings parsed at boot:
tui
cron
subagent
subagent:memory-logger
* # any channel session
slack:* # any Slack chat
slack:T0123 # one workspace
slack:T0123/C0ABCDE # one chat
slack:T0123 author:U_ME # author qualifier
slack:dm/* # any Slack DM
discord:9999 author:U_MOD
kakao:group/*Within one rule, tokens AND. Across rules on the same role, OR. Strict equality everywhere — no regex, no glob beyond listed * shapes. The parser owns precise typo errors, rejection of redundant forms (slack:*/* etc.), and rejection of legacy prefixes (team:, guild:, tg:) with a hint to use the canonical form (slack:, discord:, telegram:).
Cron and subagent provenance
These origins don't resolve via match-rule walking. They carry stamped provenance:
- Cron:
origin.scheduledByRole+origin.scheduledByOrigin(audit snapshot). Role =scheduledByRoledirectly (falls back toguestif the stamped role is missing or unknown). - Subagent:
origin.spawnedByRole+origin.spawnedByOrigin. Snapshot at spawn; parent cleanup doesn't affect it. Role =spawnedByRoledirectly (sameguestfallback). - System: the runtime-owned
systemorigin (memory logging/retrieval, backup) resolves toowner. It is constructed only by runtime/bundled code — inbound channel/cron content can never produce it — so resolving it toowneris not a laundering vector. It acts on the operator's behalf over operator-owned state, with no single user session to inherit authority from.
This closes the laundering attack: a guest who asks the agent to schedule a cron gets scheduledByRole: 'guest' stamped. When the job fires, the security plugin blocks bash env again. Hand-authored cron.json entries missing scheduledByRole are rejected with an explicit error.
Subagents do not escalate. At spawn, createSpawnSubagentTool stamps spawnedByRole = permissions.resolveRole(parentOrigin) — the parent's resolved role, captured by the runtime, not chosen by the caller. The child subagent origin then resolves back to that exact role. A guest parent spawns a guest subagent: no automatic promotion to member/trusted/owner, and every tool.before guard fires identically against the child. The subagent also receives only the tool set its definition declares — there is no inheritance of the parent's tools. Granting guest subagent.spawn is therefore capability amplification (a fresh context window + a specialized prompt for read-only/research subagents), not privilege escalation.
Wiring
- Config: top-level
roles?parsed byrolesConfigSchema.rolesis restart-required. - Plugin registration:
definePlugin({ permissions: [...] }). Read at boot before any factory runs; the union expands the owner wildcard and powers the boot-time typo warning. PluginContext.permissions: PermissionServiceis constructed once at plugin load and lives for the plugin's lifetime.ToolBeforeEvent.origincarries the LIVE origin via a mutableoriginRefonCreateSessionOptions. Channel sessions update the holder per-turn insrc/channels/router.tsso the event sees current-turnlastInboundAuthorId. TUI/cron/subagent sessions passorigindirectly.- Cron stamping:
createSessionForCronreads provenance from the persisted job.parseCronFilerejects entries withoutscheduledByRole; plugin cron defaults to'owner'viatoCronJob. - Subagent stamping: the
new-sessionstream target carriesparentSessionId,spawnedByRole, andspawnedByOriginJson.SubagentConsumerdecodes and forwards. - Plugin
ctx.spawnSubagent: hook callers pass{ parentSessionId, spawnedByOrigin: event.origin }. The runtime resolvesspawnedByRolefrom the origin via PermissionService — callers can't forge a role. - Channel router (
channel.respondgate): every inbound is gated BEFOREensureLive. Denial logs[channels] <key>: denied by permissions (channel.respond) author=<id> id=<msg>and returns. Thechannels.<adapter>.allow[]field is no longer supported — it is silently ignored on parse and NOT translated toroles.member.match[]. Defineroles.member.match[]directly.
Rules of thumb
-
Plugins declare
permissions: [...]ondefinePlugin(...), not onPluginExports. Read before the factory runs; owner-wildcard expansion is deterministic. -
Users can't write
*in their ownpermissions[]. The owner wildcard is a sentinel internal to the built-in spec. Schema enforces dotted<plugin>.<verb>.<noun>shape. -
Unknown role names resolve to
guest. Forecloses forging role strings into job records. -
Bundled security guards have a two-axis policy. Each guard exports
GUARD_*_SEVERITY('low'|'medium'|'high').tool.beforeaccepts EITHER the tier permission (security.bypass.low/medium/high) OR the per-guard permission (e.g.security.bypass.gitExfil). The tiers:- high — direct audience-leak with no operator-visible intermediate step. Owner only. Inhabitants:
outboundSecret,systemPromptLeak,gitRemoteTainted. - medium — silent-attack OR operator-reviewable state (operator sees the privileged write before the privileged effect fires). Owner + trusted. Inhabitants:
secretExfilBash,secretExfilRead,ssrf,sessionSearchSecrets,gitExfil,rolePromotion,cronPromotion. - low — noisy, immediately recoverable. Owner + trusted + member. No inhabitants today (forward-compat for member).
To narrow a role's tier cap, declare the explicit list in
roles.<role>.permissions[](replaces the default). To widen a lower role for one guard, add the per-guard string. New guards MUST export a severity constant orSEVERITY_PERMISSIONindexing fails — no silent permissive fallback.Trusted bypassing medium widens its default authority over agent state in exchange for fewer acks. Defense leans on operator review of auto-backup commits to
typeclaw.jsonandcron.json. Deployments that don't review backup commits should keep trusted's match narrow OR subtractsecurity.bypass.medium. - high — direct audience-leak with no operator-visible intermediate step. Owner only. Inhabitants:
-
The recorder-vs-checker split in the security plugin is load-bearing.
recordGitRemoteTaintIfAnyruns unconditionally for any actor who could executeset-url, independent ofcheckGitRemoteTaintedGuard's decision. The split is what makesgitExfil → mediumsafe: trusted's first-step retarget succeeds AND the recorder fires; second-step push hits the high-tiergitRemoteTaintedblock. Collapsing recorder and checker disables the two-step defense. -
Plugin-contributed cron defaults to
scheduledByRole: 'owner'. Without this, the bundled memory dreaming cron resolves toguestand loses every bypass it needs. -
Permissions gate actions, not state. Memory,
workspace/,sessions/are shared across origins. State isolation requires separate agent folders. -
No role coverage = silent agent. The
channel.respondgate is unconditional. Without matching roles, every inbound resolves toguest, which has nochannel.respond. TUI keeps working via the built-inowner.match. Denial logs name the author and channel — silent agents are diagnosable.