No description
  • TypeScript 100%
Find a file
James Peret cb2af4caa0
feat: create zero-dependency permissions-engine package
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>
2026-04-17 00:37:17 -03:00
src feat: create zero-dependency permissions-engine package 2026-04-17 00:37:17 -03:00
tests feat: create zero-dependency permissions-engine package 2026-04-17 00:37:17 -03:00
.gitignore feat: create zero-dependency permissions-engine package 2026-04-17 00:37:17 -03:00
package-lock.json feat: create zero-dependency permissions-engine package 2026-04-17 00:37:17 -03:00
package.json feat: create zero-dependency permissions-engine package 2026-04-17 00:37:17 -03:00
README.md feat: create zero-dependency permissions-engine package 2026-04-17 00:37:17 -03:00
tsconfig.json feat: create zero-dependency permissions-engine package 2026-04-17 00:37:17 -03:00

@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'