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.
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.
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.
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.
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
댓글
댓글 쓰기