/**
* Amp Permissions Extension
*
* Reads exec permissions from Amp-format settings and intercepts bash tool calls.
*
* Settings are loaded from (in order, merged):
* ~/.config/amp/settings.json (global)
* .agents/settings.json (project-local)
*
* Relevant settings keys:
*
* "amp.commands.allowlist": ["git", "npm", "./test.sh"]
* Base command names that are auto-allowed (checked before permissions rules).
* Also matched after stripping a leading "cd
&&" prefix.
*
* "amp.permissions": [
* { "tool": "Bash", "matches": { "cmd": "/\\brm\\b/" }, "action": "ask" },
* { "tool": "Bash", "matches": { "cmd": "*" }, "action": "allow" }
* ]
* Ordered rules. First matching Bash rule wins. cmd can be:
* /regex/flags — JS regex literal syntax
* * — matches any command
* — * wildcards supported; matched against the full command
* Actions: "allow" | "ask" | "deny" | "reject"
* Non-Bash rules are loaded and warned about, but otherwise ignored.
*
* Extension settings (~/.pi/agent/amplike.json):
* { "permissions": { "mode": "enabled" | "yolo" } }
* Persisted by the /permissions command across pi invocations.
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
interface AmpPermission {
tool: string;
matches?: { cmd?: string | string[] };
action: "allow" | "ask" | "deny" | "reject";
}
interface AmpSettings {
"amp.commands.allowlist"?: string[];
"amp.permissions"?: AmpPermission[];
}
// Built-in amp permission rules (from amp source, as of early 2026)
const BUILTIN_PERMISSIONS: AmpPermission[] = [
{ tool: "Bash", action: "ask", matches: { cmd: "*git*push*" } },
{
tool: "Bash",
matches: {
cmd: [
"ls", "ls *", "dir", "dir *", "cat *", "head *", "tail *", "less *", "more *",
"grep *", "egrep *", "fgrep *", "tree", "tree *", "file *", "wc *", "pwd",
"stat *", "du *", "df *", "ps *", "top", "htop", "echo *", "printenv *", "id",
"which *", "whereis *", "date", "cal *", "uptime", "free *", "ping *", "dig *",
"nslookup *", "host *", "netstat *", "ss *", "lsof *", "ifconfig *", "ip *",
"man *", "info *", "mkdir *", "touch *", "uname *", "whoami",
"go version", "go env *", "go help *",
"cargo version", "cargo --version", "cargo help *",
"rustc --version", "rustc --help", "rustc --explain *",
"javac --version", "javac -version", "javac -help", "javac --help",
"dotnet --info", "dotnet --version", "dotnet --help", "dotnet help *",
"gcc --version", "gcc -v", "gcc --help", "gcc -dumpversion",
"g++ --version", "g++ -v", "g++ --help", "g++ -dumpversion",
"clang --version", "clang --help", "clang++ --version", "clang++ --help",
"python -V", "python --version", "python -h", "python --help",
"python3 -V", "python3 --version", "python3 -h", "python3 --help",
"ruby -v", "ruby --version", "ruby -h", "ruby --help",
"node -v", "node --version", "node -h", "node --help",
"npm --help", "npm --version", "npm -v", "npm help *",
"yarn --help", "yarn --version", "yarn -v", "yarn help *",
"pnpm --help", "pnpm --version", "pnpm -v", "pnpm help *",
"pytest -h", "pytest --help", "pytest --version",
"jest --help", "jest --version", "mocha --help", "mocha --version",
"make --version", "make --help",
"docker --version", "docker --help", "docker version", "docker help *",
"git --version", "git --help", "git help *", "git version",
],
},
action: "allow",
},
{
tool: "Bash",
matches: {
cmd: [
"go test *", "go run *", "go build *", "go vet *", "go fmt *", "go list *",
"cargo test *", "cargo run *", "cargo build *", "cargo check *", "cargo fmt *", "cargo tree *",
"make -n *", "make --dry-run *",
"mvn test *", "mvn verify *", "mvn dependency:tree *",
"gradle tasks *", "gradle dependencies *", "gradle properties *",
"dotnet test *", "dotnet list *",
"python -c *", "ruby -e *", "node -e *",
"npm list *", "npm ls *", "npm outdated *", "npm test*", "npm run*", "npm view *", "npm info *",
"yarn list*", "yarn ls *", "yarn info *", "yarn test*", "yarn run *", "yarn why *",
"pnpm list*", "pnpm ls *", "pnpm outdated *", "pnpm test*", "pnpm run *",
"pytest --collect-only *", "jest --listTests *", "jest --showConfig *", "mocha --list *",
"git status*", "git show *", "git diff*", "git grep *", "git branch *", "git tag *",
"git remote -v *", "git rev-parse --is-inside-work-tree *", "git rev-parse --show-toplevel *",
"git config --list *", "git log *",
],
},
action: "allow",
},
{
tool: "Bash",
matches: {
cmd: [
"./gradlew *", "./mvnw *", "./build.sh *", "./configure *", "cmake *",
"./node_modules/.bin/tsc *", "./node_modules/.bin/eslint *",
"./node_modules/.bin/prettier *", "prettier *",
"./node_modules/.bin/tailwindcss *", "./node_modules/.bin/tsx *",
"./node_modules/.bin/vite *", "bun *", "tsx *", "vite *",
],
},
action: "allow",
},
{
tool: "Bash",
matches: {
cmd: [
".venv/bin/activate *", ".venv/Scripts/activate *",
"source .venv/bin/activate *", "source venv/bin/activate *",
"pip list *", "pip show *", "pip check *", "pip freeze *",
"uv *", "poetry show *", "poetry check *", "pipenv check *",
],
},
action: "allow",
},
{
tool: "Bash",
matches: {
cmd: [
"asdf list *", "asdf current *", "asdf which *",
"mise list *", "mise current *", "mise which *", "mise use *",
"rbenv version *", "rbenv versions *", "rbenv which *",
"nvm list *", "nvm current *", "nvm which *",
],
},
action: "allow",
},
{
tool: "Bash",
matches: {
cmd: [
"./test*", "./run_tests.sh *", "./run_*_tests.sh *", "vitest *",
"bundle exec rspec *", "bundle exec rubocop *", "rspec *", "rubocop *",
"swiftlint *", "clippy *", "ruff *", "black *", "isort *",
"mypy *", "flake8 *", "bandit *", "safety *", "biome check *", "biome format *",
],
},
action: "allow",
},
{
tool: "Bash",
matches: {
cmd: [
"rails server *", "rails s *", "bin/rails server *", "bin/rails s *",
"flask run *", "django-admin runserver *", "python manage.py runserver *",
"uvicorn *", "streamlit run *",
],
},
action: "allow",
},
{
tool: "Bash",
matches: {
cmd: [
"bin/rails db:status", "bin/rails db:version",
"rails db:rollback *", "rails db:status *", "rails db:version *",
"alembic current *", "alembic history *",
"bundle exec rails db:status", "bundle exec rails db:version",
],
},
action: "allow",
},
{
tool: "Bash",
matches: {
cmd: [
"docker ps *", "docker images *", "docker logs *", "docker inspect *",
"docker info *", "docker stats *", "docker system df *", "docker system info *",
"podman ps *", "podman images *", "podman logs *", "podman inspect *", "podman info *",
],
},
action: "allow",
},
{
tool: "Bash",
matches: {
cmd: [
"aws --version *", "aws configure list *", "aws sts get-caller-identity *", "aws s3 ls *",
"gcloud config list *", "gcloud auth list *", "gcloud projects list *",
"az account list *", "az account show *",
"kubectl get *", "kubectl describe *", "kubectl logs *", "kubectl version *",
"helm list *", "helm status *", "helm version *",
],
},
action: "allow",
},
{
tool: "Bash",
matches: {
cmd: [
"swift build *", "swift test *", "zig build *", "zig build test*",
"kotlinc *", "scalac *", "javac *", "javap *", "clang *", "jar *",
"sbt *", "gradle *", "bazel build *", "bazel test *", "bazel run *",
"mix *", "lua *", "ruby *", "php *",
],
},
action: "allow",
},
{
tool: "Bash",
matches: { cmd: ["mkdir -p *", "chmod +x *", "dos2unix *", "unix2dos *", "ln -s *"] },
action: "allow",
},
{
tool: "Bash",
matches: {
cmd: [
"for *", "while *", "do *", "done *", "if *", "then *", "else *",
"elif *", "fi *", "case *", "esac *", "in *", "function *",
"select *", "until *", "{ *", "} *", "[[ *", "]] *",
],
},
action: "ask",
},
{ tool: "Bash", matches: { cmd: "/^find(?!.*(-delete|-exec|-execdir)).*$/" }, action: "allow" },
{
tool: "Bash",
matches: { cmd: "/^(echo|ls|pwd|date|whoami|id|uname)\\s.*[&|;].*\\s*(echo|ls|pwd|date|whoami|id|uname)($|\\s.*)/" },
action: "allow",
},
{
tool: "Bash",
matches: { cmd: "/^(cat|grep|head|tail|less|more|find)\\s.*\\|\\s*(grep|head|tail|less|more|wc|sort|uniq)($|\\s.*)/" },
action: "allow",
},
{
tool: "Bash",
matches: { cmd: "/^rm\\s+.*(-[rf].*-[rf]|-[rf]{2,}|--recursive.*--force|--force.*--recursive).*$/" },
action: "ask",
},
{ tool: "Bash", matches: { cmd: "/^find.*(-delete|-exec|-execdir).*$/" }, action: "ask" },
{ tool: "Bash", matches: { cmd: "/^(ls|cat|grep|head|tail|file|stat)\\s+[^/]*$/" }, action: "allow" },
{
tool: "Bash",
matches: { cmd: "/^(?!.*(rm|mv|cp|chmod|chown|sudo|su|dd)\\b).*/dev/(null|zero|stdout|stderr|stdin).*$/" },
action: "allow",
},
// Default: ask for any unmatched Bash command
{ tool: "Bash", action: "ask" },
];
// Prefix that agents commonly prepend: "cd /some/dir && "
const CD_PREFIX_RE = /^cd[^;&]*?&&\s*/;
function loadSettings(paths: string[]): AmpSettings {
const merged: AmpSettings = {};
for (const path of paths) {
try {
const data = JSON.parse(readFileSync(path, "utf8")) as AmpSettings;
if (data["amp.commands.allowlist"]) {
merged["amp.commands.allowlist"] = [
...(merged["amp.commands.allowlist"] ?? []),
...data["amp.commands.allowlist"],
];
}
if (data["amp.permissions"]) {
merged["amp.permissions"] = [
...(merged["amp.permissions"] ?? []),
...data["amp.permissions"],
];
}
} catch {
// File not found or invalid JSON — skip
}
}
return merged;
}
function getBaseCommand(command: string): string {
return command.trim().replace(CD_PREFIX_RE, "").trim().split(/\s+/)[0] ?? "";
}
function globToRegex(glob: string): RegExp {
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
return new RegExp(`^${escaped}$`);
}
function matchesCmd(pattern: string | string[], command: string): boolean {
if (Array.isArray(pattern)) {
return pattern.some((p) => matchesCmd(p, command));
}
if (pattern === "*") return true;
// Regex literal: /pattern/ or /pattern/flags
const regexMatch = pattern.match(/^\/(.+)\/([gimsuy]*)$/);
if (regexMatch) {
try {
return new RegExp(regexMatch[1], regexMatch[2]).test(command);
} catch {
return false;
}
}
// Glob: match against full command
return globToRegex(pattern).test(command);
}
function ruleAppliesToBash(rule: AmpPermission): boolean {
// Simple glob check: does this tool pattern match "Bash"?
if (rule.tool === "Bash") return true;
if (rule.tool === "*") return true;
try {
return globToRegex(rule.tool).test("Bash");
} catch {
return false;
}
}
const GLOBAL_SETTINGS = join(homedir(), ".config", "amp", "settings.json");
// Extension settings file — follows the ~/.pi/agent/.json convention
const AMPLIKE_SETTINGS_PATH = join(homedir(), ".pi", "agent", "amplike.json");
interface AmplikeSettings {
permissions?: {
mode?: "enabled" | "yolo";
};
}
function loadAmplikeSettings(): AmplikeSettings {
try {
return JSON.parse(readFileSync(AMPLIKE_SETTINGS_PATH, "utf8")) as AmplikeSettings;
} catch {
return {};
}
}
function saveAmplikeSettings(settings: AmplikeSettings): void {
const dir = dirname(AMPLIKE_SETTINGS_PATH);
mkdirSync(dir, { recursive: true });
const tmp = `${AMPLIKE_SETTINGS_PATH}.tmp.${process.pid}`;
writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n", "utf8");
renameSync(tmp, AMPLIKE_SETTINGS_PATH);
}
// Permission mode: "enabled" (default) or "yolo" (all commands allowed without checks)
// Loaded from amplike.json on startup; persisted on /permissions toggle.
let permissionMode: "enabled" | "yolo" = loadAmplikeSettings().permissions?.mode ?? "enabled";
export default function (pi: ExtensionAPI) {
pi.registerCommand("permissions", {
description: "Toggle permission mode between 'enabled' (amp rules) and 'yolo' (all commands allowed)",
handler: async (_args, ctx) => {
if (permissionMode === "enabled") {
permissionMode = "yolo";
ctx.ui.setStatus("permissions", "YOLO mode");
ctx.ui.notify("Permissions: switched to YOLO mode — all bash commands allowed without checks", "warning");
} else {
permissionMode = "enabled";
ctx.ui.setStatus("permissions", undefined);
ctx.ui.notify("Permissions: switched to enabled mode — amp permission rules active", "info");
}
const current = loadAmplikeSettings();
saveAmplikeSettings({ ...current, permissions: { ...current.permissions, mode: permissionMode } });
},
});
pi.on("session_start", async (_event, ctx) => {
// Restore status bar if yolo mode was persisted from a previous session
if (permissionMode === "yolo") {
ctx.ui.setStatus("permissions", "YOLO mode");
}
// Warn about any non-Bash permission rules in the user's config
const settings = loadSettings([GLOBAL_SETTINGS, resolve(ctx.cwd, ".agents", "settings.json")]);
const nonBashRules = (settings["amp.permissions"] ?? []).filter((r) => !ruleAppliesToBash(r));
if (nonBashRules.length > 0) {
const tools = [...new Set(nonBashRules.map((r) => r.tool))].join(", ");
ctx.ui.notify(
`permissions: ignoring ${nonBashRules.length} non-Bash amp.permissions rule(s) (tools: ${tools})`,
"warning",
);
}
});
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "bash") return undefined;
// YOLO mode: bypass all permission checks
if (permissionMode === "yolo") return undefined;
const command = event.input.command as string;
const strippedCommand = command.trim().replace(CD_PREFIX_RE, "").trim();
const projectSettings = resolve(ctx.cwd, ".agents", "settings.json");
const settings = loadSettings([GLOBAL_SETTINGS, projectSettings]);
const allowlist = settings["amp.commands.allowlist"] ?? [];
const userRules = settings["amp.permissions"] ?? [];
const baseCmd = getBaseCommand(command);
const NO_MATCH = Symbol();
type RuleOutcome = typeof NO_MATCH | undefined | { block: true; reason: string };
async function applyRules(rules: AmpPermission[]): Promise {
for (const rule of rules) {
if (!ruleAppliesToBash(rule)) continue;
const cmdPattern = rule.matches?.cmd;
if (cmdPattern !== undefined && !matchesCmd(cmdPattern, strippedCommand)) continue;
// Rule matched — resolve action
if (rule.action === "allow") return undefined;
if (rule.action === "deny" || rule.action === "reject") return { block: true, reason: "Denied by amp permissions" };
if (rule.action === "ask") {
if (!ctx.hasUI) return { block: true, reason: "Command requires confirmation (no UI available)" };
const choice = await ctx.ui.select(
`⚠️ Permission required:\n\n ${command}\n\nAllow? (Use /permissions to toggle YOLO mode and skip these checks)`,
["Yes", "No"],
);
if (choice !== "Yes") {
ctx.abort();
return { block: true, reason: "Blocked by user" };
}
return undefined;
}
}
return NO_MATCH;
}
// User rules first (take precedence over allowlist + built-ins)
const userResult = await applyRules(userRules);
if (userResult !== NO_MATCH) return userResult;
// Allowlist: after user rules, before built-ins
if (allowlist.includes(baseCmd)) return undefined;
// Built-in rules as final fallback
const builtinResult = await applyRules(BUILTIN_PERMISSIONS);
return builtinResult === NO_MATCH ? undefined : builtinResult;
});
}