TypeClawTypeClaw
Internals

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 under typeclaw.json#roles.
  • Permission<plugin>.<verb>.<noun> string. Plugins declare via definePlugin({ permissions: [...] }).

Resolution walks roles in severity-then-declaration order: ownertrusted → custom (reverse declaration; later wins) → memberguest. 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)

RoleBuilt-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 = scheduledByRole directly (falls back to guest if the stamped role is missing or unknown).
  • Subagent: origin.spawnedByRole + origin.spawnedByOrigin. Snapshot at spawn; parent cleanup doesn't affect it. Role = spawnedByRole directly (same guest fallback).
  • System: the runtime-owned system origin (memory logging/retrieval, backup) resolves to owner. It is constructed only by runtime/bundled code — inbound channel/cron content can never produce it — so resolving it to owner is 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

  1. Config: top-level roles? parsed by rolesConfigSchema. roles is restart-required.
  2. Plugin registration: definePlugin({ permissions: [...] }). Read at boot before any factory runs; the union expands the owner wildcard and powers the boot-time typo warning.
  3. PluginContext.permissions: PermissionService is constructed once at plugin load and lives for the plugin's lifetime.
  4. ToolBeforeEvent.origin carries the LIVE origin via a mutable originRef on CreateSessionOptions. Channel sessions update the holder per-turn in src/channels/router.ts so the event sees current-turn lastInboundAuthorId. TUI/cron/subagent sessions pass origin directly.
  5. Cron stamping: createSessionForCron reads provenance from the persisted job. parseCronFile rejects entries without scheduledByRole; plugin cron defaults to 'owner' via toCronJob.
  6. Subagent stamping: the new-session stream target carries parentSessionId, spawnedByRole, and spawnedByOriginJson. SubagentConsumer decodes and forwards.
  7. Plugin ctx.spawnSubagent: hook callers pass { parentSessionId, spawnedByOrigin: event.origin }. The runtime resolves spawnedByRole from the origin via PermissionService — callers can't forge a role.
  8. Channel router (channel.respond gate): every inbound is gated BEFORE ensureLive. Denial logs [channels] <key>: denied by permissions (channel.respond) author=<id> id=<msg> and returns. The channels.<adapter>.allow[] field is no longer supported — it is silently ignored on parse and NOT translated to roles.member.match[]. Define roles.member.match[] directly.

Rules of thumb

  • Plugins declare permissions: [...] on definePlugin(...), not on PluginExports. Read before the factory runs; owner-wildcard expansion is deterministic.

  • Users can't write * in their own permissions[]. 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.before accepts 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 or SEVERITY_PERMISSION indexing 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.json and cron.json. Deployments that don't review backup commits should keep trusted's match narrow OR subtract security.bypass.medium.

  • The recorder-vs-checker split in the security plugin is load-bearing. recordGitRemoteTaintIfAny runs unconditionally for any actor who could execute set-url, independent of checkGitRemoteTaintedGuard's decision. The split is what makes gitExfil → medium safe: trusted's first-step retarget succeeds AND the recorder fires; second-step push hits the high-tier gitRemoteTainted block. Collapsing recorder and checker disables the two-step defense.

  • Plugin-contributed cron defaults to scheduledByRole: 'owner'. Without this, the bundled memory dreaming cron resolves to guest and 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.respond gate is unconditional. Without matching roles, every inbound resolves to guest, which has no channel.respond. TUI keeps working via the built-in owner.match. Denial logs name the author and channel — silent agents are diagnosable.

On this page