Skip to content

Permission Model

wcore-permissions is the explicit-grant access control layer for Wayland Core. It defines a (Actor, Resource, Action) ACL engine, SHA-256-signed session bearer tokens with configurable TTL and revocation, and a LearnedPolicy that persists tool-approval decisions across sessions.


Every authorization decision in PolicyEngine is an (Actor, Resource, Action) triple.

Actors:

VariantMeaning
Actor::User(String)A named human identity
Actor::Agent(String)A sub-agent or spawned worker (e.g. "worker-1")
Actor::SystemThe engine itself - internal book-keeping calls; hard-coded bypass, not an externally-presentable identity

Resources:

VariantMatching
Resource::Tool(String)Exact tool name
Resource::File(String)Glob pattern (see path-traversal protection below)
Resource::McpServer(String)Exact MCP server name
Resource::Memory(String)Tier name

Actions: Invoke, Read, Write, Delete.


PolicyEngine (crates/wcore-permissions/src/policy.rs) is an in-memory ACL store. Add grants with grant(), check access with check():

let mut engine = PolicyEngine::new();
engine.grant(Permission {
actor: Actor::User("alice".into()),
resource: Resource::File("/home/alice/project/**".into()),
action: Action::Write,
});
// Returns Ok(()) - alice may write under her project tree.
engine.check(
&Actor::User("alice".into()),
&Resource::File("/home/alice/project/src/main.rs".into()),
Action::Write,
)?;
// Returns Err(DenyReason::NoMatchingGrant) - bob has no grants.
engine.check(
&Actor::User("bob".into()),
&Resource::File("/home/alice/project/src/main.rs".into()),
Action::Write,
)?;

check() returns Ok(()) on allow and Err(DenyReason) on deny. DenyReason distinguishes NoMatchingGrant (no matching triple at all) from PathNotInAllowlist (a matching actor/action/resource-kind tuple exists but the specific path did not match the glob). This lets callers surface different error messages.

Actor::System bypasses all ACL checks - it represents internal engine calls, not a user-presentable identity.

Resource::File grants use a minimal glob matcher. Supported patterns:

PatternMatches
**Any path
<prefix>/**The prefix itself or any path beneath it
**/<suffix>Any path ending in <suffix>
(anything else)Exact string match

Any pattern or path containing a .. path segment is rejected before matching. A grant for /tmp/workspace/** cannot be defeated by /tmp/workspace/../etc/passwd. Both forward and backslash separators are checked so Windows-style paths are covered.

An optional GrantAuditSink can be installed via set_audit_sink. When present, every grant() call emits a GrantAuditEvent { permission, at_ms } to the sink. Sink failures are silent (observability only, never gating). This closes threat T7 in the threat-model document.


BearerToken (crates/wcore-permissions/src/token.rs) provides SHA-256-signed session tokens with a configurable TTL.

let secret = b"session-secret-bytes";
let ttl_ms = 30 * 60 * 1000; // 30 minutes
// Issue
let token = BearerToken::issue(Actor::User("alice".into()), ttl_ms, secret);
// Verify (checks expiry then signature)
let actor = token.verify(secret)?;
// Verify with revocation check (revocation checked first)
let actor = token.verify_with_store(secret, &revocation_store)?;
// Rotate under a new secret (preserves original expiry, cannot extend TTL)
let rotated = token.rotate(new_secret)?;

The token id (token.id()) is the signature_hex field - unique per (actor, issued_at_ms, expires_at_ms, secret) tuple. The Debug implementation prints "<redacted>" for signature_hex to prevent replay leakage through log output (closes threat T6).

Signing scheme note: the signature is SHA-256 over secret || payload, not a true HMAC. The crate documentation states: “not a true HMAC; cheap integrity check sufficient for a single trusted-secret world.” Ed25519 asymmetric signing is planned post-v0.6 when an external identity provider is wired.


RevocationStore (crates/wcore-permissions/src/revocation.rs) is a trait with two methods: revoke(token_id) and is_revoked(token_id).

The default implementation is SqliteRevocationStore, behind the sqlite-revocation cargo feature (on by default). It stores revoked token IDs in a local SQLite file:

CREATE TABLE IF NOT EXISTS revoked (
token_id TEXT PRIMARY KEY,
revoked_at INTEGER NOT NULL -- unix-ms wall clock
);

revoke() uses INSERT OR IGNORE so revoking the same ID twice is a no-op. In verify_with_store(), the revocation check runs before the signature check - a revoked token is rejected immediately, regardless of signature validity.

To use a different backing store (e.g. Redis or an in-memory store for tests), implement the RevocationStore trait and pass it to verify_with_store.


LearnedPolicy (crates/wcore-permissions/src/learning.rs) persists tool-approval decisions to ~/.wayland/permissions.toml. It is the mechanism that lets users pre-answer “always allow git commands” rather than approving each dispatch individually.

Decision types:

DecisionMeaning
AllowOnceAllow this specific call; consume the rule afterward
AllowAlwaysAllow all matching calls; rule persists
DenyOnceDeny this call; consume the rule afterward
DenyAlwaysDeny all matching calls; rule persists

Pattern matching: rules match by tool name plus an optional argument glob. Specificity wins: a rule with arg_pattern = "git *" beats a wildcard arg_pattern = "*", which beats no pattern. Exact literal patterns and trailing-* prefix patterns are supported; leading * and mid-string * are intentionally not supported.

evaluate() returns EvalResult::Match { allow, pattern } when a rule matches, or EvalResult::Ask when no rule covers the call - at which point the engine or TUI prompts the user and calls record() with the decision.

Example ~/.wayland/permissions.toml (TOML-serialized by the engine, not hand-edited):

[[rules]]
tool = "Bash"
arg_pattern = "git *"
decision = "allow-always"
[[rules]]
tool = "Bash"
arg_pattern = "*"
decision = "deny-always"
[[rules]]
tool = "Read"
decision = "allow-always"

The specific-beats-wildcard property means git status hits the "git *" allow rule, not the "*" deny rule. A non-git Bash call hits the "*" deny rule.

record_once_consumed() clears a *-Once rule after it has been used. AllowAlways / DenyAlways rules survive the call.


CallActor (crates/wcore-permissions/src/actor.rs) distinguishes two call origins:

  • CallActor::Root - a user-initiated tool call; bypasses the sub-agent pre-filter and goes straight to the approval gate
  • CallActor::SubAgent { id, parent_id } - a sub-agent-initiated call; subject to the LearnedPolicy pre-filter before reaching the human approval gate

This lets the learned policy screen routine sub-agent tool dispatches (e.g. a read-heavy research agent calling Read repeatedly) without requiring a human prompt for every call, while still routing novel or destructive calls through the approval gate.


The JSON-stream protocol carries these approval-related messages:

MessageDirectionMeaning
ApprovalRequired { call_id, resume_token, reason, context }Engine → HostEngine is waiting for consent before dispatching a tool
ToolApprove { call_id, scope }Host → EngineHost grants approval
ToolDeny { call_id, reason }Host → EngineHost denies
ApprovalResume { resume_token, approved }Host → EngineResume a suspended tool

scope in ToolApprove can be Once, Always, or AlwaysPrefix { prefix }. AlwaysPrefix scopes always-allow to commands whose argv head matches a literal prefix - for example, allowing all cargo subcommands without allowing all bash invocations.