@thurstonsand/pi-permissions
v0.11.0
Published
Pi extension package for user, project, and package-level tool permission hooks
Readme
@thurstonsand/pi-permissions
pi-permissions adds a permissions gate for Pi tool calls. You can write small TypeScript modules that inspect a pending tool call and either let it pass, ask the approver, or block it before it runs.
Install
pi install npm:@thurstonsand/pi-permissionsRestart Pi after installing.
The package includes a user-only skill that helps you author and test permissions:
/skill:create-permission Ask before the agent pushes Git commitsFor local development from a clone:
pi -e ./extensions/index.tsWhere you can write permissions
| Scope | Location | Loads when |
| ------- | -------------------------------------------------- | --------------------------------------------- |
| User | ~/.pi/agent/permissions/*.ts | Every session |
| Project | .pi/permissions/*.ts | The project is trusted |
| Package | pi.permissions or permissions/ in a Pi package | The package is installed and not filtered out |
Permission modules are TypeScript modules, using the same basic trust model as Pi extensions. Project permissions only load after Pi trusts the project.
Hooks run in this order:
- project-level permissions
- user-level permissions
- package-level permissions
and stops on the first hook that requests or blocks.
Writing a permission module
A permission module default-exports a function. Register checks with api.onToolUse(). This module is also available as examples/git-commit.ts:
import {
matchCommand,
matchTool,
request,
type PermissionsAPI,
} from "@thurstonsand/pi-permissions";
const gitCommit = matchCommand({
program: "git",
subcommands: ["commit"],
onMatch: () =>
request({ guidance: "Review the commit message before approving." }),
});
export default function permissions(api: PermissionsAPI) {
api.onToolUse({
name: "git commit",
description: "Ask before the agent creates a commit.",
handler(input) {
return matchTool(input.tool, { bash: gitCommit });
},
});
}
Each hook has:
name: short label shown in prompts and logsdescription: explanation shown to the approver if a request is madehandler: code that returns a decision, or returns nothing to keep evaluating other hooks
Handlers receive a PermissionInput:
input.cwd; // current Pi working directory
input.permissionRoot; // directory containing the permission module
input.tool; // normalized tool inputFor built-in tools, input.tool includes typed convenience fields:
bash.command;
read.projectPath;
edit.absolutePath;
write.path;For custom tools, use isCustomToolInput() or the custom branch of matchTool() to narrow by exact tool name.
Decisions are one of:
return request(); // default request behavior
return request({
guidance: "Check the target environment.",
highlight: /production|prod-db/i,
approveLabel: "Approve",
editLabel: "Edit",
rejectLabel: "Reject",
});
return block("Do not edit generated files directly.");guidance adds request-specific text to the prompt. highlight emphasizes offending fragments of the tool detail with a string, RegExp, array of either, precomputed spans, or a callback that returns spans. If no highlight is provided, it uses the matched command by default. approveLabel, editLabel, and rejectLabel change the button labels for that request.
A highlight callback receives the rendered tool detail and returns half-open { start, end } offsets. Use highlightSpans(detail, pattern) inside a callback when you need pattern matching plus a little extra filtering.
Useful exports:
| Export | Use |
| ------------------------------- | --------------------------------------------------- |
| PermissionsAPI | Type for the module factory argument |
| request() | Ask the approver before the tool runs |
| block() | Block the tool with an agent-facing reason |
| matchTool() | Branch on built-in and custom tool inputs |
| highlightSpans() | Resolve highlight strings, RegExps, and spans |
| parseShellCommand() | Parse bash into span-carrying simple commands |
| matchCommand() | Match bash by program, subcommand, or predicate |
| gitValueFlags | Git value-taking flags for subcommand resolution |
| isBashToolInput() | Narrow a normalized tool input to Pi's bash tool |
| isReadToolInput() | Narrow to Pi's read tool |
| isEditToolInput() | Narrow to Pi's edit tool |
| isWriteToolInput() | Narrow to Pi's write tool |
| isGrepToolInput() | Narrow to Pi's grep tool |
| isFindToolInput() | Narrow to Pi's find tool |
| isLsToolInput() | Narrow to Pi's ls tool |
| isCustomToolInput(tool, name) | Narrow to an extension/custom Pi tool by exact name |
Examples
Every example below is also available as a runnable module in examples/. Copy one into your user- or project-level permissions directory and adapt it to your workflow.
Ask before recursive forced removal
import {
matchCommand,
matchTool,
request,
type SimpleCommand,
type PermissionsAPI,
} from "@thurstonsand/pi-permissions";
function isDestructiveRemoval(cmd: SimpleCommand): boolean {
return cmd.programName === "rm"
? cmd.hasFlag("-r", "-R", "--recursive") && cmd.hasFlag("-f", "--force")
: cmd.hasFlag("-delete");
}
const destructiveRemoval = matchCommand({
program: ["rm", "find"],
where: isDestructiveRemoval,
onMatch: ({ commands }) =>
request({ highlight: commands.map((cmd) => cmd.span) }),
});
export default function permissions(api: PermissionsAPI) {
api.onToolUse({
name: "destructive removal",
description: "Ask before recursive forced removal or find deletion.",
handler(input) {
return matchTool(input.tool, { bash: destructiveRemoval });
},
});
}
where narrows matches by an arbitrary predicate the same way subcommands narrows by name; onMatch only fires when at least one command passes all filters.
Block reading .env
import {
block,
matchTool,
type PermissionsAPI,
} from "@thurstonsand/pi-permissions";
export default function permissions(api: PermissionsAPI) {
api.onToolUse({
name: "read env file",
description: "Do not expose local secrets to the LLM.",
handler(input) {
return matchTool(input.tool, {
read(tool) {
if (tool.projectPath === ".env") {
return block("Reading .env could expose local secrets to the LLM.");
}
},
});
},
});
}
Ask before a pi-mcp-adapter tool
pi-mcp-adapter can expose MCP tools directly as Pi tools. If a GitHub MCP server exposes a direct tool named github_create_release, you can match it like any other Pi tool.
import {
matchTool,
request,
type PermissionsAPI,
} from "@thurstonsand/pi-permissions";
export default function permissions(api: PermissionsAPI) {
api.onToolUse({
name: "GitHub release",
description: "Ask before creating a release through pi-mcp-adapter.",
handler(input) {
return matchTool(input.tool, {
custom: {
github_create_release(tool) {
return request({
guidance: `Check the tag, target repository, and release notes.\n\n${tool.detail}`,
approveLabel: "Create release",
rejectLabel: "Cancel release",
});
},
},
});
},
});
}
Package-bundled permissions
A Pi package can ship permissions alongside the pi-native extensions, skills, prompts, or themes.
{
"name": "my-pi-package",
"pi": {
"extensions": ["./extensions/index.ts"],
"permissions": ["./permissions/index.ts"]
}
}If pi.permissions is omitted, pi-permissions also checks for a top-level permissions/ directory.
And you can choose exactly which permissions to use in your pi settings where you declare that package:
{
"packages": [
{
"source": "npm:my-pi-package",
"permissions": ["permissions/*.ts", "!permissions/legacy.ts"]
}
]
}An empty permissions array disables permissions from that package.
Responding to permission requests
When a hook returns request(), Pi pauses before running the tool.
Approving runs the tool. If the approver adds a note, that note is passed back into the session as context.
Rejecting blocks the tool. A rejection with a note tells the agent how to proceed; a rejection without a note aborts the current turn. Hitting esc also aborts the turn.
ctrl+s approves and disables the deciding permission check for the rest of the session branch, exactly as if you had approved and then run /permissions disable <name>. Any note drafted on the approve choice still travels with the approval.
For bash tool calls, Edit opens the command and an optional note in a multiline editor.

Managing permissions
Use /permissions to review loaded hooks and choose which permission checks are enabled in the current session branch.

Inside the permissions modal:
j/kor arrow keys navigatespacetoggles the selected permissiongtoggles all currently loaded permissionsentersaves and closesesccloses without saving
Commands:
/permissions
/permissions enable
/permissions disable
/permissions enable Git mutations
/permissions disable Git mutations/permissions enable and /permissions disable apply to all permissions.
You can also toggle all currently loaded permissions via Alt+P, customizable in ~/.pi/agent/settings.json:
{
"permissions": {
"toggleShortcut": "alt+p"
}
}Run /reload after changing settings.
Development
This repo uses mise for local commands.
mise run check
mise run testRun Pi against the local extension entrypoint:
pi -e ./extensions/index.ts