- TypeScript 100%
New package with all pure rule types, checker functions (path, command, remote, URL), and standalone PermissionEngine class. Safe for npm publishing and standalone Node.js use. 57 tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|---|---|---|
| src | ||
| tests | ||
| .gitignore | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
@fractal-synapse/permissions-engine
Zero-dependency permission rule engine for Fractal Synapse. Evaluates tool, path, command, remote, and URL permission rules from a declarative JSON file and returns 'allow', 'deny', or 'ask' for each check.
This package has no runtime dependencies -- it only uses Node.js built-ins. It is safe to publish to npm and use in standalone processes (such as remote-node-server) that need the same permission logic as the SecurityPermissionsPlugin but run outside the agent runtime.
Installation
npm install @fractal-synapse/permissions-engine
Basic Usage
Load a permissions JSON file, create an engine instance, and run checks:
import { PermissionEngine } from '@fractal-synapse/permissions-engine';
import { readFileSync } from 'fs';
const data = JSON.parse(readFileSync('security-permissions.json', 'utf-8'));
const engine = new PermissionEngine(data, 'deny');
engine.checkCommand('rm -rf /tmp'); // 'deny'
engine.checkPath('/home/user/work'); // 'allow'
engine.checkRemote('dev-server'); // 'allow'
engine.checkUrl('https://api.github.com/repos'); // 'allow'
The second argument to the constructor is the fallback behavior when no rule matches and the JSON file itself has no defaultBehavior set. It defaults to 'deny'.
Hot Reload
Use updateData() to swap the rule set at runtime without recreating the engine:
import { watchFile } from 'fs';
watchFile('security-permissions.json', () => {
try {
engine.updateData(JSON.parse(readFileSync('security-permissions.json', 'utf-8')));
} catch { /* keep current rules on parse error */ }
});
updateData() is safe to call at any time -- the engine immediately begins using the new data for all subsequent checks.
Permissions File Reference
A full annotated example showing every supported rule type:
{
"version": 1,
"defaultBehavior": "deny", // fallback when no rule matches: "allow" | "deny" | "ask"
// Tool-level permissions: tool name -> permission
"tools": {
"bash": "allow",
"edit-file": "allow",
"read-file": "allow",
"web-fetch": "ask"
},
// Path rules: longest prefix match wins
"paths": [
{ "path": "/home/user/projects", "permission": "allow" },
{ "path": "/home/user/projects/internal", "permission": "deny" },
{ "path": "/etc", "permission": "deny" }
],
// Command rules: shell-command prefix matching, deny wins across subcommands
"commands": [
{ "pattern": "ls", "permission": "allow" },
{ "pattern": "git", "permission": "allow" },
{ "pattern": "npm", "permission": "allow" },
{ "pattern": "rm", "permission": "deny" },
{ "pattern": "sudo", "permission": "deny" },
{ "pattern": "curl", "permission": "deny" }
],
// Remote rules: exact string match on the node name
"remotes": [
{ "name": "dev-server", "permission": "allow" },
{ "name": "prod-server", "permission": "deny" }
],
// URL rules: longest prefix match on the full URL
"urls": [
{ "url": "https://api.github.com/", "permission": "allow" },
{ "url": "https://api.github.com/repos/private/", "permission": "deny" },
{ "url": "https://malicious.example.com/", "permission": "deny" }
]
}
API Reference
new PermissionEngine(data, defaultBehavior?)
| Parameter | Type | Description |
|---|---|---|
data |
PermissionsData |
Parsed permissions JSON object |
defaultBehavior |
'allow' | 'deny' | 'ask' |
Fallback when no rule matches and data.defaultBehavior is absent. Defaults to 'deny' |
checkTool(toolName: string): Permission
Looks up the tool name in the tools map. Returns the configured permission or falls back to defaultBehavior.
checkPath(path: string): Permission
Checks a file-system path against the paths rule array using longest-prefix match. Falls back to defaultBehavior when no rule matches.
checkCommand(command: string): Permission
Checks a shell command string against the commands rule array. The command is split on shell operators (&&, ||, ;, |) and each subcommand is checked independently. If any subcommand matches a deny rule, the entire command is denied. Falls back to defaultBehavior.
checkRemote(name: string): Permission
Checks a remote node name against the remotes rule array using exact string match. Falls back to defaultBehavior when no rule matches.
checkUrl(url: string): Permission
Checks a URL against the urls rule array using longest-prefix match. Falls back to defaultBehavior when no rule matches.
updateData(data: PermissionsData): void
Replaces the active permissions data. The engine immediately uses the new rules for all subsequent checks. Call this to implement hot-reload without recreating the engine instance.
All check* methods return 'allow', 'deny', or 'ask'.
Standalone Checker Functions
Each check* method on PermissionEngine delegates to a standalone pure function that is also exported directly. These functions return Permission | undefined -- undefined means no rule matched, and the caller decides the fallback behavior.
| Function | Signature | Description |
|---|---|---|
checkPathPermission |
(path: string, rules: PathRule[]) => Permission | undefined |
Longest-prefix match on file-system paths |
checkCommandPermission |
(command: string, rules: CommandRule[]) => Permission | undefined |
Shell-operator-aware command matching, deny wins |
checkRemotePermission |
(name: string, rules: RemoteRule[]) => Permission | undefined |
Exact string match on remote node names |
checkUrlPermission |
(url: string, rules: UrlRule[]) => Permission | undefined |
Longest-prefix match on URLs |
The following utility functions are also exported:
| Function | Description |
|---|---|
normalizePath(path: string): string |
Normalize a path for consistent matching |
isPathUnderRule(path: string, rule: PathRule): boolean |
Check whether a path falls under a single rule's prefix |
extractBaseCommand(command: string): string |
Extract the first word of a shell command |
splitIntoSubcommands(command: string): string[] |
Split a command string on &&, ` |
extractPathsFromCommand(command: string): string[] |
Extract path-like tokens from a command string |
Rule Evaluation Logic
Paths and URLs -- Longest Match Wins
Both path and URL rules use longest-prefix match. When multiple rules match a given input, the rule with the longest prefix takes priority:
// Given these path rules:
// /home/user/projects -> allow
// /home/user/projects/internal -> deny
engine.checkPath('/home/user/projects/internal/secret'); // 'deny' (longer match wins)
engine.checkPath('/home/user/projects/myapp'); // 'allow' (only shorter rule matches)
For URLs, the prefix is normalized to end with / when it has no path component, which prevents false matches across domains:
// Rule "https://api.github.com/" matches "https://api.github.com/v1/users"
// but does NOT match "https://api.github.company.com/"
Remotes -- Exact Match
Remote rules use exact case-sensitive string match. A partial or case-variant name does not match:
// Rule: { name: "dev-server", permission: "allow" }
engine.checkRemote('dev-server'); // 'allow'
engine.checkRemote('dev'); // 'deny' (falls back to defaultBehavior)
engine.checkRemote('Dev-Server'); // 'deny' (case-sensitive)
Commands -- Deny Wins
Command rules match by prefix against each subcommand (after splitting on shell operators). If any subcommand is denied, the entire command is denied:
// Given: rm -> deny, ls -> allow
engine.checkCommand('rm -rf /tmp'); // 'deny'
engine.checkCommand('ls && rm -rf /tmp'); // 'deny' (rm subcommand is denied)
engine.checkCommand('ls -la /home'); // 'allow'