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.
Actor, Resource, Action
Section titled “Actor, Resource, Action”Every authorization decision in PolicyEngine is an (Actor, Resource, Action) triple.
Actors:
| Variant | Meaning |
|---|---|
Actor::User(String) | A named human identity |
Actor::Agent(String) | A sub-agent or spawned worker (e.g. "worker-1") |
Actor::System | The engine itself - internal book-keeping calls; hard-coded bypass, not an externally-presentable identity |
Resources:
| Variant | Matching |
|---|---|
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
Section titled “PolicyEngine”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.
Path-traversal protection
Section titled “Path-traversal protection”Resource::File grants use a minimal glob matcher. Supported patterns:
| Pattern | Matches |
|---|---|
** | 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.
Grant audit sink
Section titled “Grant audit sink”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.
Bearer tokens
Section titled “Bearer tokens”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
// Issuelet 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.
Token revocation
Section titled “Token revocation”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.
Learned approval policy
Section titled “Learned approval policy”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:
| Decision | Meaning |
|---|---|
AllowOnce | Allow this specific call; consume the rule afterward |
AllowAlways | Allow all matching calls; rule persists |
DenyOnce | Deny this call; consume the rule afterward |
DenyAlways | Deny 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.
Sub-agent ACL pre-filter
Section titled “Sub-agent ACL pre-filter”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 gateCallActor::SubAgent { id, parent_id }- a sub-agent-initiated call; subject to theLearnedPolicypre-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.
Protocol approval gates
Section titled “Protocol approval gates”The JSON-stream protocol carries these approval-related messages:
| Message | Direction | Meaning |
|---|---|---|
ApprovalRequired { call_id, resume_token, reason, context } | Engine → Host | Engine is waiting for consent before dispatching a tool |
ToolApprove { call_id, scope } | Host → Engine | Host grants approval |
ToolDeny { call_id, reason } | Host → Engine | Host denies |
ApprovalResume { resume_token, approved } | Host → Engine | Resume 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.
Related pages
Section titled “Related pages”- Security Model Overview - sandbox, egress gate, budget caps, threat model
- Credential Storage - how API keys are stored at rest
- Plan Mode - read-only session mode that blocks all writes
- JSON-Stream Protocol - full protocol message reference