SuperPi.Plugins.Permissions (super_pi v0.1.0)

Copy Markdown View Source

Permissions, Execution Modes, & Workspace Confinement Plugin (SuperPi.Extension behaviour).

Enforces execution modes and workspace boundaries:

  • dangerouslySkipPermissions: true: Auto-approves all tool executions without prompting.
  • executionMode: "plan": Allows read-only inspection tools (read, grep, find, ls, tool_search), tools whose only effect is read, and network reads (see Tool effects). Blocks everything else (write, edit, bash, state changes).
  • executionMode: "accept-edits": Allows read, write, edit, grep, find, ls inside trusted workspaces and checks permissions.allow (command(...) entries in settings.json) for bash commands.
  • allowNonWorkspaceAccess: Defaults to false. File tools may only touch workspacePaths and trustedWorkspaces. Set it to true to lift the confinement.

Tool effects

A tool declares what a call does (SuperPi.Tools.effects/2): read, fs_write, exec, net_read, net_write, state_write. The decision is a pure function of effects, mode and policy (decide/4):

Effectsplanaccept-editsauto
only readallowallowallow
net_read, with readallow unless network_in_plan_mode: falseallowallow
state_write, with read and net_readblockallowallow
fs_write, exec, net_write, or no declared effectsblockblock (the write and edit tools and allowed shell commands excepted)allow

Network policy keys, orthogonal to the mode: network: "allow" | "allowlist" | "deny" (default "allow") and network_in_plan_mode (default true). deny refuses every tool with a network effect. allowlist allows a call whose url argument has a host in networkAllowHosts (an entry matches the host and its subdomains) and refuses a call without one.

A harness can set maxExecutionMode in pi.agent. A mode above it is lowered to it (clamp_mode/1).

Policy

Policy is data. It merges, lowest priority first, from:

  1. config :super_pi, :permissions, ... (keys such as allowNonWorkspaceAccess, permissions)
  2. <app data dir>/settings.json
  3. the session's settingsOverride

permissions.deny and permissions.allow hold command(...) patterns. A deny match blocks a shell command in every mode. allow opens commands in accept-edits mode.

Modes for remote callers

A Gateway, RPC or MCP caller can request a mode. remote_mode/1 limits the request:

config :super_pi, :permissions,
  remote_default_mode: "accept-edits",
  remote_max_mode: "accept-edits"

A request may select any mode up to remote_max_mode (plan < accept-edits < auto). The ceiling defaults to the default mode, so a request can only tighten the mode unless config raises the ceiling. sanitize_remote_agent/1 also removes keys that would let a remote caller lift the policy (dangerouslySkipPermissions, settingsOverride, paths).

Summary

Functions

Sets executionMode in agent_cfg for a remote caller.

Lowers agent_cfg["executionMode"] to the harness ceiling agent_cfg["maxExecutionMode"]. A config without a ceiling, or with a mode at or below it, is returned as it is.

Checks whether a shell command matches any command(...) pattern in settings.json's permissions.allow list. Matches exact commands (command(git status)) or command prefixes (command(head) matching head -n 20 file.txt).

The decision for one call, as a pure function of the tool, its arguments, the agent config and the effective settings. before_tool/3 loads the last two and decides through evaluate/4.

Like decide/4, with a third answer: {:prompt, reason, risk} for a block that a human may lift. Exactly two blocks are promptable: a shell command that is not in permissions.allow, and a tool that accept-edits does not allow by default (every MCP tool among them). A permissions.deny match, a mode block, a workspace boundary and a network policy block are never promptable.

The effective network policy: "allow" (default), "allowlist" or "deny".

Rank of a mode: plan 0, accept-edits 1, auto 2. An unknown mode ranks as plan, the strictest.

Returns the mode that a remote caller gets for a request.

Removes the keys from a client-supplied agent map that would lift the permission policy, and limits executionMode with remote_mode/1.

True when path is root or inside it, after following symlinks component by component. Both must be absolute. A link inside root that points outside it is not inside.

Functions

apply_remote_mode(agent_cfg, requested)

@spec apply_remote_mode(map(), term()) :: map()

Sets executionMode in agent_cfg for a remote caller.

With requested of nil, the harness default is limited to the ceiling. A harness default that is not a permission mode (for example "interactive") is left as it is.

clamp_mode(agent_cfg)

@spec clamp_mode(map()) :: map()

Lowers agent_cfg["executionMode"] to the harness ceiling agent_cfg["maxExecutionMode"]. A config without a ceiling, or with a mode at or below it, is returned as it is.

command_allowed?(cmd_str, allow_list)

Checks whether a shell command matches any command(...) pattern in settings.json's permissions.allow list. Matches exact commands (command(git status)) or command prefixes (command(head) matching head -n 20 file.txt).

decide(tool_name, args, agent_cfg, settings)

@spec decide(String.t(), map(), map(), map()) :: :allow | {:block, String.t()}

The decision for one call, as a pure function of the tool, its arguments, the agent config and the effective settings. before_tool/3 loads the last two and decides through evaluate/4.

The decision depends on the tool's effects (SuperPi.Tools.effects/2), the mode and the policy, and it never loosens as the mode rises (plan < accept-edits < auto).

description()

evaluate(tool_name, args, agent_cfg, settings)

@spec evaluate(String.t(), map(), map(), map()) ::
  :allow | {:block, String.t()} | {:prompt, String.t(), String.t()}

Like decide/4, with a third answer: {:prompt, reason, risk} for a block that a human may lift. Exactly two blocks are promptable: a shell command that is not in permissions.allow, and a tool that accept-edits does not allow by default (every MCP tool among them). A permissions.deny match, a mode block, a workspace boundary and a network policy block are never promptable.

id()

load_effective_settings(agent_cfg)

name()

network_policy(settings)

@spec network_policy(map()) :: String.t()

The effective network policy: "allow" (default), "allowlist" or "deny".

rank_of(mode)

@spec rank_of(term()) :: 0..2

Rank of a mode: plan 0, accept-edits 1, auto 2. An unknown mode ranks as plan, the strictest.

remote_mode(requested \\ nil)

@spec remote_mode(term()) :: String.t()

Returns the mode that a remote caller gets for a request.

requested is the mode that the caller asked for, or nil. An unknown or missing value gives remote_default_mode. A value above remote_max_mode is lowered to it.

sanitize_remote_agent(agent)

@spec sanitize_remote_agent(map()) :: map()

Removes the keys from a client-supplied agent map that would lift the permission policy, and limits executionMode with remote_mode/1.

within?(path, root)

@spec within?(String.t(), String.t()) :: boolean()

True when path is root or inside it, after following symlinks component by component. Both must be absolute. A link inside root that points outside it is not inside.