Skip to content

Plugin Capability System

Overview

Implementations SHOULD use a capability-based security model for plugins, granting only the minimum permissions required for each plugin's functionality.

Design Principles

Principle of Least Privilege

Each plugin should have access to only what it needs:

+---------------------------------------------------------+
|                    Capability Model                      |
+---------------------------------------------------------+
|  Plugin A (price fetcher)                               |
|    [x] read_prices                                      |
|    [x] network_https (api.example.com only)             |
|    [ ] write_directives                                 |
|    [ ] filesystem                                       |
+---------------------------------------------------------+
|  Plugin B (auto_accounts)                               |
|    [x] read_accounts                                    |
|    [x] write_directives (open only)                     |
|    [ ] network                                          |
|    [ ] filesystem                                       |
+---------------------------------------------------------+

Capability Tokens

Capabilities are unforgeable tokens that grant specific permissions:

rust
/// A capability token granting specific permissions
pub struct Capability {
    kind: CapabilityKind,
    scope: CapabilityScope,
    // Cryptographically signed by host
}

pub enum CapabilityKind {
    ReadDirectives,
    WriteDirectives,
    ReadAccounts,
    ReadPrices,
    Network,
    FileRead,
    FileWrite,
}

pub enum CapabilityScope {
    All,
    Filtered { accounts: Vec<AccountPattern> },
    Domain { hosts: Vec<String> },
    Path { prefix: PathBuf },
}

Standard Capabilities

Directive Access

CapabilityDescriptionRisk Level
read:directivesRead all directivesLow
read:directives:transactionsRead transactions onlyLow
read:directives:pricesRead price directivesLow
write:directivesEmit new directivesMedium
write:directives:openEmit Open directives onlyLow
modify:directivesModify existing directivesHigh

Account Access

CapabilityDescriptionRisk Level
read:accountsRead account namesLow
read:accounts:balancesRead account balancesMedium
read:accounts:pattern:Assets:*Read matching accountsLow

Network Access

CapabilityDescriptionRisk Level
network:noneNo network access (default)None
network:https:*.example.comHTTPS to specific domainsMedium
network:allUnrestricted networkHigh

Filesystem Access

CapabilityDescriptionRisk Level
file:noneNo filesystem access (default)None
file:read:ledgerRead files in ledger directoryLow
file:read:path:/data/*Read from specific pathMedium
file:write:path:/output/*Write to specific pathHigh

Capability Declaration

In Plugin Manifest

Plugins declare required capabilities:

toml
# plugin.toml
[plugin]
name = "price_fetcher"
version = "1.0.0"

[capabilities.required]
read = ["directives:prices", "accounts"]
network = ["https:api.exchangerate.com"]

[capabilities.optional]
file = ["read:ledger"]  # For caching

At Load Time

Host verifies and grants capabilities:

rust
fn load_plugin(path: &Path, policy: &SecurityPolicy) -> Result<Plugin> {
    let manifest = PluginManifest::load(path)?;

    // Check required capabilities against policy
    for cap in &manifest.capabilities.required {
        if !policy.allows(cap) {
            return Err(PluginError::CapabilityDenied(cap.clone()));
        }
    }

    // Grant capabilities as tokens
    let capabilities = manifest.capabilities.required
        .iter()
        .map(|c| policy.grant(c))
        .collect();

    Plugin::new(path, capabilities)
}

Capability Enforcement

At Runtime

rust
impl PluginRuntime {
    fn read_accounts(&self, pattern: &str) -> Result<Vec<Account>> {
        // Check capability
        self.require_capability(Capability::ReadAccounts)?;

        // If scoped, filter results
        let accounts = self.ledger.accounts();
        if let Some(scope) = self.capability_scope(Capability::ReadAccounts) {
            return Ok(accounts.filter(|a| scope.matches(a)).collect());
        }

        Ok(accounts.collect())
    }

    fn emit_directive(&mut self, directive: Directive) -> Result<()> {
        // Check capability
        self.require_capability(Capability::WriteDirectives)?;

        // If scoped, validate directive type
        if let Some(scope) = self.capability_scope(Capability::WriteDirectives) {
            if !scope.allows_directive(&directive) {
                return Err(PluginError::CapabilityViolation);
            }
        }

        self.output.push(directive);
        Ok(())
    }
}

Capability Revocation

Capabilities can be revoked mid-execution:

rust
impl PluginRuntime {
    fn revoke(&mut self, capability: CapabilityKind) {
        self.capabilities.remove(&capability);
        // Future calls using this capability will fail
    }
}

Security Policies

Built-in Policies

rust
pub enum SecurityPolicy {
    /// No capabilities (maximum security)
    Deny,

    /// Read-only access to directives
    ReadOnly,

    /// Standard plugin permissions
    Standard,

    /// Custom policy
    Custom(CapabilitySet),
}

impl SecurityPolicy {
    pub fn standard() -> Self {
        Self::Custom(CapabilitySet::new()
            .add(Capability::ReadDirectives)
            .add(Capability::ReadAccounts)
            .add(Capability::WriteDirectives)
        )
    }
}

User Configuration

beancount
; Global plugin policy
option "plugin_policy" "standard"

; Per-plugin overrides
plugin "price_fetcher" {
  capabilities: [
    "read:directives:prices",
    "network:https:api.exchangerate.com"
  ]
}

plugin "custom_validator" {
  capabilities: [
    "read:directives",
    "read:accounts"
  ]
}

Command Line

bash
# Run with strict policy
beancount --plugin-policy=readonly ledger.beancount

# Grant specific capability to plugin
beancount --plugin-capability=price_fetcher:network:https:* ledger.beancount

Capability Audit

Logging

rust
impl PluginRuntime {
    fn use_capability(&self, cap: &Capability) {
        // Log all capability usage
        log::info!(
            plugin = self.name,
            capability = %cap,
            "Plugin used capability"
        );
    }
}

Audit Report

Plugin Capability Audit
=======================

price_fetcher v1.0.0
  Used capabilities:
    - read:directives:prices (152 times)
    - network:https:api.exchangerate.com (12 requests)
  Unused declared capabilities:
    - file:read:ledger (optional, never used)

auto_accounts v2.1.0
  Used capabilities:
    - read:accounts (1 time)
    - write:directives:open (3 times)
  Attempted denied capabilities:
    - write:directives:transaction (BLOCKED)

Error Messages

error: Plugin capability denied
  --> main.beancount:3:1
  |
3 | plugin "untrusted_plugin"
  | ^^^^^^^^^^^^^^^^^^^^^^^^^ requires capability not granted
  |
  = plugin: untrusted_plugin
  = required: network:https:*
  = policy: standard (no network access)
  = hint: add --plugin-capability=untrusted_plugin:network:https:example.com
error: Plugin capability violation
  --> [plugin: auto_accounts]
  |
  = attempted: write transaction directive
  = granted: write:directives:open only
  = hint: plugin tried to emit Transaction but only has Open permission

Recommendations

  1. Declare capabilities explicitly in plugin manifests
  2. Default to minimal permissions - deny by default
  3. Scope capabilities narrowly - use patterns and domains
  4. Audit capability usage - log all access
  5. Review capability requests before granting
  6. Separate read and write capabilities
  7. Time-limit capabilities for sensitive operations