Plugin Permission Scopes: Least-Privilege Design That Actually Holds

If your plugin system grants each plugin broad access to system resources, a single vulnerable plugin becomes a full-system breach. This post walks through how to structurally enforce the Principle of Least Privilege in a plugin architecture — from manifest design through runtime gating to audit-driven scope reduction.

This is aimed at engineers building extensible platforms: VS Code-style plugin hosts, workflow automation tools, or any system where third-party code runs inside your process.

overall permission enforcement pipeline


The Problem: Coarse Permissions Are a Liability

Most plugin systems start with something like: "permissions": ["read", "write"]. It's quick to implement, and it works — until it doesn't.

The issue isn't just that you're granting too much. It's that coarse permissions are invisible. You can't tell which plugin actually used which resource, you can't shrink access over time, and you can't detect when a plugin starts touching things it shouldn't.

The first thing I tried was a simple enum check:

// ❌ This breaks down immediately
if (plugin.permissions.includes("file:read")) {
  allowAccess();
}

This tells you nothing about which files, which paths, or which hosts. A log-parser plugin that declares file:read could technically read /etc/passwd and you'd never know until after the incident.

coarse vs fine-grained scope comparison


The Fix: Three-Dimensional Scope Model

The core insight is that a permission isn't just a resource type. It's a tuple of resource × actions × constraints. You need all three to make least-privilege meaningful.

Here's the TypeScript shape I settled on:

interface PluginScope {
  resource: ResourceType;       // "file" | "network" | "env" | "db"
  actions: Action[];            // ["read"] | ["read", "write"] | ["execute"]
  constraints: ScopeConstraint;
}

interface ScopeConstraint {
  pathPattern?: string[];       // ["/tmp/**", "/data/readonly/**"]
  networkHosts?: string[];      // ["api.example.com"]
  envKeyPattern?: string[];     // ["APP_*", "!APP_SECRET_*"]
  maxPayloadBytes?: number;
}

Plugins declare what they need at manifest time. This is the contract — and it's reviewable by humans before the plugin ever runs:

# plugin.manifest.yaml
name: log-parser
version: 1.2.0
scopes:
  - resource: file
    actions: [read]
    constraints:
      pathPattern:
        - "/var/log/app/*.log"
  - resource: network
    actions: [read]
    constraints:
      networkHosts:
        - "metrics.internal"

The manifest is your audit trail before runtime even starts. When a PR changes this file, reviewers immediately see what new access is being requested. I'll come back to automating that review step.

Runtime Gate

Declaration alone is just documentation. You need enforcement at the moment of access. Every resource request from a plugin goes through a PermissionGate that checks the granted scopes:

class PermissionGate {
  private grantedScopes: Map<string, PluginScope[]>;

  check(pluginId: string, request: ResourceRequest): boolean {
    const scopes = this.grantedScopes.get(pluginId) ?? [];
    return scopes.some(scope =>
      scope.resource === request.resource &&
      scope.actions.includes(request.action) &&
      this.matchesConstraints(scope.constraints, request)
    );
  }

  private matchesConstraints(
    constraints: ScopeConstraint,
    request: ResourceRequest
  ): boolean {
    if (constraints.pathPattern && request.path) {
      return constraints.pathPattern.some(p =>
        micromatch.isMatch(request.path!, p)
      );
    }
    if (constraints.networkHosts && request.host) {
      return constraints.networkHosts.includes(request.host);
    }
    return true;
  }
}

Run plugins in an isolated execution context — Worker threads, V8 Isolates, or a Wasm sandbox — and route every syscall through this gate. The plugin never gets a raw file handle or network socket; it gets proxied access through the gate.


Escalation Vectors to Close

Getting the basic gate right is step one. What worked for me was then going through common escalation paths one by one, because the gate alone won't catch them.

three privilege escalation paths

1. Path traversal via symlinks

Even with a glob pattern like /var/log/app/*.log, a symlink inside that directory can point anywhere. Always normalize and validate:

function resolveSafePath(base: string, input: string): string | null {
  const resolved = path.resolve(base, input);
  if (!resolved.startsWith(base)) return null; // block traversal
  return resolved;
}

Call fs.realpath() before this check if symlinks are a concern in your environment.

2. Delegation chain escalation

Plugin A has narrow file access. Plugin B has broad network access. A calls B, and suddenly A's logic is running with B's permissions. The fix is to pass the originating plugin ID through the call stack and intersect scopes — never union them:

function mergeScopes(
  callerScopes: PluginScope[],
  calleeScopes: PluginScope[]
): PluginScope[] {
  // intersection only — never grant the superset
  return callerScopes.filter(cs =>
    calleeScopes.some(ces =>
      ces.resource === cs.resource &&
      cs.actions.every(a => ces.actions.includes(a))
    )
  );
}

3. Environment variable leakage

Passing process.env to a plugin is the most common unintentional over-grant I've seen. Build a filtered snapshot instead:

function filterEnv(
  env: NodeJS.ProcessEnv,
  patterns: string[]
): Record<string, string> {
  return Object.fromEntries(
    Object.entries(env).filter(([key]) =>
      patterns.some(p => micromatch.isMatch(key, p))
    )
  );
}

In the manifest, envKeyPattern: ["APP_*", "!APP_SECRET_*"] gives you allowlist + denylist in one pass if your glob library supports negation patterns. micromatch does.


Audit Logs and Scope Drift Detection

Every gate decision — allowed or denied — goes into a structured audit log:

interface AuditEntry {
  timestamp: number;
  pluginId: string;
  resource: string;
  action: string;
  path?: string;
  allowed: boolean;
  reason?: string; // "scope_not_found" | "constraint_mismatch"
}

The real value here is the feedback loop. Permissions tend to accumulate over time — nobody removes them. Audit logs let you run the pressure in the opposite direction.

Two signals to watch:

Signal Meaning Action
Declared scope never appears in allowed logs (30d) Over-privileged Flag for removal at next release
Repeated allowed: false for same resource Bug or malicious probe Alert + plugin suspension

Here's the jq query I run weeklyagainst the audit log to find unused scopes:

# Find scopes declared but never exercised in 30 days
jq '
  group_by(.pluginId) |
  map({
    plugin: .[0].pluginId,
    used_scopes: (
      [.[] | select(.allowed == true) | "\(.resource):\(.action)"] |
      unique
    )
  })
' audit.jsonl

Cross-reference this output against each plugin's manifest to identify the gap. What's in the manifest but not in used_scopes is your removal list.

audit feedback loop closing the scope lifecycle


Automating Scope Review in CI

The last piece is catching scope expansion before it lands. When plugin.manifest.yaml changes in a PR, generate a human-readable diff and require security sign-off:

# .github/workflows/scope-review.yml
on:
  pull_request:
    paths:
      - "**/plugin.manifest.yaml"

jobs:
  scope-diff:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 2 }
      - run: |
          git diff HEAD~1 -- '**/plugin.manifest.yaml' \
            | python scripts/scope_diff_report.py \
            | gh pr comment --body-file -

scope_diff_report.py parses the unified diff and renders something like:

⚠️  Scope changes detected in plugins/log-parser/plugin.manifest.yaml

  ADDED:
    + resource: db, actions: [read], constraints: { table: "events" }

  REMOVED:
    - resource: network, constraints: { networkHosts: ["metrics.internal"] }

Set a CODEOWNERS rule requiring security-team approval on any PR that adds a new resource type or broadens a pathPattern. This prevents scope creep at the structural level rather than relying on reviewer attention.


Variations and Gotchas

Docker / container isolation: If you're running plugins in containers, Linux namespaces give you filesystem isolation for free — but you still need the gate for inter-plugin delegation and env filtering. Don't rely on container boundaries alone.

macOS vs Linux path behavior: path.resolve() on macOS may follow symlinks differently than on Linux depending on your Node version. Test resolveSafePath on both if your dev and prod environments differ.

Wasm sandboxes: If you're using a Wasm runtime (Wasmer, Wasmtime), the host function interface is your gate layer. Bind only the file/network/env functions that align with granted scopes, and leave everything else unbound — unbound host functions are hard errors, not soft denials.

Scope versioning: When a plugin bumps from v1 to v2 and changes its scopes, treat this as a new grant request, not an update. Your manifest loader should diff scopes between versions and prompt re-approval for any expansions.


The principle that made this click for me: scope declarations aren't a one-time setup, they're a feedback mechanism. Declaration → enforcement → audit → trim → re-declare. When the loop closes automatically, least-privilege stops being a policy aspiration and starts being a structural property of your system.

Next up: enforcing scope budgets at the plugin marketplace level — how to gate what scopes a publisher can even request before a human reviewer sees the manifest.


🐦 Faster updates on X: @baegseungh7061
📚 More in this series: All posts
💌 Subscribe: Follow on X or grab the RSS

댓글