diff --git a/.failproofai/policies/review-policies.mjs b/.failproofai/policies/review-policies.mjs
new file mode 100644
index 00000000..04342353
--- /dev/null
+++ b/.failproofai/policies/review-policies.mjs
@@ -0,0 +1,112 @@
+/**
+ * review-policies.mjs — Require bot review comments to be resolved before stopping.
+ *
+ * Runs on the Stop event, after built-in workflow policies (require-ci-green-before-stop).
+ * Uses the GitHub GraphQL API to check for unresolved review threads authored by bots.
+ */
+import { customPolicies, allow, deny } from "failproofai";
+import { execSync } from "node:child_process";
+
+customPolicies.add({
+ name: "require-bot-reviews-resolved",
+ description: "Require all bot review comments (e.g. CodeRabbit) to be resolved before stopping",
+ match: { events: ["Stop"] },
+ fn: async (ctx) => {
+ const cwd = ctx.session?.cwd;
+ if (!cwd) return allow("No working directory, skipping bot review check.");
+
+ try {
+ execSync("gh --version", { cwd, encoding: "utf8", timeout: 3000 });
+ } catch {
+ return allow("GitHub CLI (gh) not installed, skipping bot review check.");
+ }
+
+ let branch;
+ try {
+ branch = execSync("git rev-parse --abbrev-ref HEAD", {
+ cwd,
+ encoding: "utf8",
+ timeout: 5000,
+ }).trim();
+ } catch {
+ return allow("Could not determine branch, skipping bot review check.");
+ }
+
+ if (!branch || branch === "HEAD" || branch === "main" || branch === "master") {
+ return allow("Not on a feature branch, skipping bot review check.");
+ }
+
+ let prNumber;
+ try {
+ const raw = execSync(`gh pr view "${branch}" --json number`, {
+ cwd,
+ encoding: "utf8",
+ timeout: 10000,
+ });
+ prNumber = JSON.parse(raw).number;
+ } catch {
+ return allow("No PR found for this branch, skipping bot review check.");
+ }
+
+ let repoOwner, repoName;
+ try {
+ const raw = execSync("gh repo view --json owner,name", {
+ cwd,
+ encoding: "utf8",
+ timeout: 5000,
+ });
+ const parsed = JSON.parse(raw);
+ repoOwner = parsed.owner.login;
+ repoName = parsed.name;
+ } catch {
+ return allow("Could not determine repository, skipping bot review check.");
+ }
+
+ const query = `query {
+ repository(owner: "${repoOwner}", name: "${repoName}") {
+ pullRequest(number: ${prNumber}) {
+ reviewThreads(first: 100) {
+ nodes {
+ isResolved
+ comments(first: 1) {
+ nodes {
+ author { login }
+ }
+ }
+ }
+ }
+ }
+ }
+ }`.replace(/\n/g, " ");
+
+ let threads;
+ try {
+ const raw = execSync(`gh api graphql -f query='${query}'`, {
+ cwd,
+ encoding: "utf8",
+ timeout: 15000,
+ });
+ threads = JSON.parse(raw).data.repository.pullRequest.reviewThreads.nodes;
+ } catch {
+ return allow("Could not fetch review threads, skipping bot review check.");
+ }
+
+ const unresolvedBotThreads = threads.filter((t) => {
+ if (t.isResolved) return false;
+ const author = t.comments?.nodes?.[0]?.author?.login ?? "";
+ return author.includes("[bot]");
+ });
+
+ if (unresolvedBotThreads.length > 0) {
+ const authors = [
+ ...new Set(unresolvedBotThreads.map((t) => t.comments.nodes[0].author.login)),
+ ];
+ return deny(
+ `${unresolvedBotThreads.length} unresolved bot review comment(s) on PR #${prNumber} from: ${authors.join(", ")}. ` +
+ `Address or resolve all bot review comments, then push your fixes before stopping.`,
+ );
+ }
+
+ return allow();
+ },
+});
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bd189729..f7930a68 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,14 @@
## Unreleased
+## 0.0.6-beta.1 — 2026-04-20
+
+### Features
+- Add `prefer-package-manager` builtin policy to enforce allowed package managers (e.g., uv instead of pip) (#126)
+
+### Docs
+- Emphasize convention-based policies as org-wide quality standards in getting-started, custom-policies, examples, and README (#126)
+
## 0.0.6-beta.0 — 2026-04-20
### Fixes
diff --git a/README.md b/README.md
index e82c6d52..7646a417 100644
--- a/README.md
+++ b/README.md
@@ -222,7 +222,7 @@ Custom hooks support transitive local imports, async/await, and access to `proce
### Convention-based policies
-Drop `*policies.{js,mjs,ts}` files into `.failproofai/policies/` and they're automatically loaded — no `--custom` flag or config changes needed. Works like git hooks: drop a file, it just works.
+Drop `*policies.{js,mjs,ts}` files into `.failproofai/policies/` and they're automatically loaded — no flags or config changes needed. Commit the directory to git and every team member gets the same quality standards automatically.
```text
# Project level — committed to git, shared with the team
@@ -233,7 +233,7 @@ Drop `*policies.{js,mjs,ts}` files into `.failproofai/policies/` and they're aut
~/.failproofai/policies/my-policies.mjs
```
-Both levels load (union). Files are loaded alphabetically within each directory. Prefix with `01-`, `02-`, etc. to control order. See [examples/convention-policies/](examples/convention-policies/) for ready-to-use examples.
+Both levels load (union). Files are loaded alphabetically within each directory. Prefix with `01-`, `02-`, etc. to control order. As your team discovers new failure modes, add a policy and push — everyone gets the update on their next pull. See [examples/convention-policies/](examples/convention-policies/) for ready-to-use examples.
---
diff --git a/__tests__/e2e/hooks/policy-params.e2e.test.ts b/__tests__/e2e/hooks/policy-params.e2e.test.ts
index 0dbfb784..b5cbc608 100644
--- a/__tests__/e2e/hooks/policy-params.e2e.test.ts
+++ b/__tests__/e2e/hooks/policy-params.e2e.test.ts
@@ -240,6 +240,40 @@ describe("block-read-outside-cwd allowPaths", () => {
});
});
+// ── prefer-package-manager — allowed ────────────────────────────────────────
+
+describe("prefer-package-manager allowed", () => {
+ it("denies pip when only uv is allowed", () => {
+ const env = createFixtureEnv();
+ env.writeConfig({
+ enabledPolicies: ["prefer-package-manager"],
+ policyParams: { "prefer-package-manager": { allowed: ["uv"] } },
+ });
+ const result = runHook("PreToolUse", Payloads.preToolUse.bash("pip install flask", env.cwd), { homeDir: env.home });
+ assertPreToolUseDeny(result);
+ });
+
+ it("allows when command uses an allowed manager", () => {
+ const env = createFixtureEnv();
+ env.writeConfig({
+ enabledPolicies: ["prefer-package-manager"],
+ policyParams: { "prefer-package-manager": { allowed: ["uv"] } },
+ });
+ const result = runHook("PreToolUse", Payloads.preToolUse.bash("uv add flask", env.cwd), { homeDir: env.home });
+ assertAllow(result);
+ });
+
+ it("allows when allowed list is empty (no-op)", () => {
+ const env = createFixtureEnv();
+ env.writeConfig({
+ enabledPolicies: ["prefer-package-manager"],
+ policyParams: { "prefer-package-manager": { allowed: [] } },
+ });
+ const result = runHook("PreToolUse", Payloads.preToolUse.bash("pip install flask", env.cwd), { homeDir: env.home });
+ assertAllow(result);
+ });
+});
+
// ── hint — cross-cutting policyParams field ─────────────────────────────────
describe("policyParams hint", () => {
diff --git a/__tests__/hooks/builtin-policies.test.ts b/__tests__/hooks/builtin-policies.test.ts
index bc8b1ef0..636cb7d6 100644
--- a/__tests__/hooks/builtin-policies.test.ts
+++ b/__tests__/hooks/builtin-policies.test.ts
@@ -34,8 +34,8 @@ describe("hooks/builtin-policies", () => {
});
describe("BUILTIN_POLICIES", () => {
- it("has 30 built-in policies", () => {
- expect(BUILTIN_POLICIES).toHaveLength(30);
+ it("has 31 built-in policies", () => {
+ expect(BUILTIN_POLICIES).toHaveLength(31);
});
it("has 11 default-enabled policies", () => {
@@ -1327,6 +1327,226 @@ describe("hooks/builtin-policies", () => {
});
});
+ describe("prefer-package-manager", () => {
+ const policy = BUILTIN_POLICIES.find((p) => p.name === "prefer-package-manager")!;
+
+ it("denies pip install when uv is preferred", async () => {
+ const ctx = makeCtx({
+ toolName: "Bash",
+ toolInput: { command: "pip install flask" },
+ params: { allowed: ["uv"] },
+ });
+ const result = await policy.fn(ctx);
+ expect(result.decision).toBe("deny");
+ expect(result.reason).toContain("uv");
+ expect(result.reason).toContain("pip");
+ });
+
+ it("denies pip3 install", async () => {
+ const ctx = makeCtx({
+ toolName: "Bash",
+ toolInput: { command: "pip3 install requests" },
+ params: { allowed: ["uv"] },
+ });
+ expect((await policy.fn(ctx)).decision).toBe("deny");
+ });
+
+ it("denies python -m pip", async () => {
+ const ctx = makeCtx({
+ toolName: "Bash",
+ toolInput: { command: "python -m pip install django" },
+ params: { allowed: ["uv"] },
+ });
+ expect((await policy.fn(ctx)).decision).toBe("deny");
+ });
+
+ it("denies python3 -m pip", async () => {
+ const ctx = makeCtx({
+ toolName: "Bash",
+ toolInput: { command: "python3 -m pip install django" },
+ params: { allowed: ["uv"] },
+ });
+ expect((await policy.fn(ctx)).decision).toBe("deny");
+ });
+
+ it("denies pip freeze (read-only blocked too)", async () => {
+ const ctx = makeCtx({
+ toolName: "Bash",
+ toolInput: { command: "pip freeze" },
+ params: { allowed: ["uv"] },
+ });
+ expect((await policy.fn(ctx)).decision).toBe("deny");
+ });
+
+ it("denies npm install when bun is preferred", async () => {
+ const ctx = makeCtx({
+ toolName: "Bash",
+ toolInput: { command: "npm install express" },
+ params: { allowed: ["bun"] },
+ });
+ const result = await policy.fn(ctx);
+ expect(result.decision).toBe("deny");
+ expect(result.reason).toContain("bun");
+ });
+
+ it("denies npx when bun is preferred", async () => {
+ const ctx = makeCtx({
+ toolName: "Bash",
+ toolInput: { command: "npx create-react-app my-app" },
+ params: { allowed: ["bun"] },
+ });
+ expect((await policy.fn(ctx)).decision).toBe("deny");
+ });
+
+ it("allows uv pip install when uv is allowed", async () => {
+ const ctx = makeCtx({
+ toolName: "Bash",
+ toolInput: { command: "uv pip install flask" },
+ params: { allowed: ["uv"] },
+ });
+ expect((await policy.fn(ctx)).decision).toBe("allow");
+ });
+
+ it("allows uv add when uv is allowed", async () => {
+ const ctx = makeCtx({
+ toolName: "Bash",
+ toolInput: { command: "uv add flask" },
+ params: { allowed: ["uv"] },
+ });
+ expect((await policy.fn(ctx)).decision).toBe("allow");
+ });
+
+ it("allows bun install when bun is allowed", async () => {
+ const ctx = makeCtx({
+ toolName: "Bash",
+ toolInput: { command: "bun install express" },
+ params: { allowed: ["bun"] },
+ });
+ expect((await policy.fn(ctx)).decision).toBe("allow");
+ });
+
+ it("allows when allowed list is empty (no-op)", async () => {
+ const ctx = makeCtx({
+ toolName: "Bash",
+ toolInput: { command: "pip install flask" },
+ params: { allowed: [] },
+ });
+ expect((await policy.fn(ctx)).decision).toBe("allow");
+ });
+
+ it("allows commands with no package manager", async () => {
+ const ctx = makeCtx({
+ toolName: "Bash",
+ toolInput: { command: "ls -la" },
+ params: { allowed: ["uv"] },
+ });
+ expect((await policy.fn(ctx)).decision).toBe("allow");
+ });
+
+ it("allows non-Bash tool", async () => {
+ const ctx = makeCtx({
+ toolName: "Read",
+ toolInput: { file_path: "/some/file" },
+ params: { allowed: ["uv"] },
+ });
+ expect((await policy.fn(ctx)).decision).toBe("allow");
+ });
+
+ it("supports multiple allowed managers", async () => {
+ const ctx = makeCtx({
+ toolName: "Bash",
+ toolInput: { command: "poetry add flask" },
+ params: { allowed: ["uv", "poetry"] },
+ });
+ expect((await policy.fn(ctx)).decision).toBe("allow");
+ });
+
+ it("deny message includes allowed managers", async () => {
+ const ctx = makeCtx({
+ toolName: "Bash",
+ toolInput: { command: "pip install flask" },
+ params: { allowed: ["uv", "poetry"] },
+ });
+ const result = await policy.fn(ctx);
+ expect(result.decision).toBe("deny");
+ expect(result.reason).toContain("uv, poetry");
+ });
+
+ it("denies user-specified blocked manager", async () => {
+ const ctx = makeCtx({
+ toolName: "Bash",
+ toolInput: { command: "pdm install flask" },
+ params: { allowed: ["uv"], blocked: ["pdm"] },
+ });
+ const result = await policy.fn(ctx);
+ expect(result.decision).toBe("deny");
+ expect(result.reason).toContain("pdm");
+ });
+
+ it("denies user-specified blocked manager (pipx)", async () => {
+ const ctx = makeCtx({
+ toolName: "Bash",
+ toolInput: { command: "pipx run black ." },
+ params: { allowed: ["uv"], blocked: ["pipx"] },
+ });
+ expect((await policy.fn(ctx)).decision).toBe("deny");
+ });
+
+ it("allows user-specified blocked manager if also in allowed", async () => {
+ const ctx = makeCtx({
+ toolName: "Bash",
+ toolInput: { command: "pdm install flask" },
+ params: { allowed: ["uv", "pdm"], blocked: ["pdm"] },
+ });
+ expect((await policy.fn(ctx)).decision).toBe("allow");
+ });
+
+ it("allows command not matching any blocked entry", async () => {
+ const ctx = makeCtx({
+ toolName: "Bash",
+ toolInput: { command: "git status" },
+ params: { allowed: ["uv"], blocked: ["pdm"] },
+ });
+ expect((await policy.fn(ctx)).decision).toBe("allow");
+ });
+
+ it("denies pip in compound command even when uv appears in another segment", async () => {
+ const ctx = makeCtx({
+ toolName: "Bash",
+ toolInput: { command: "uv --version && pip install flask" },
+ params: { allowed: ["uv"] },
+ });
+ expect((await policy.fn(ctx)).decision).toBe("deny");
+ });
+
+ it("allows both segments when both use allowed managers", async () => {
+ const ctx = makeCtx({
+ toolName: "Bash",
+ toolInput: { command: "uv add flask && bun install express" },
+ params: { allowed: ["uv", "bun"] },
+ });
+ expect((await policy.fn(ctx)).decision).toBe("allow");
+ });
+
+ it("denies blocked manager in piped command", async () => {
+ const ctx = makeCtx({
+ toolName: "Bash",
+ toolInput: { command: "cat requirements.txt | pip install -r -" },
+ params: { allowed: ["uv"] },
+ });
+ expect((await policy.fn(ctx)).decision).toBe("deny");
+ });
+
+ it("denies blocked manager after semicolon", async () => {
+ const ctx = makeCtx({
+ toolName: "Bash",
+ toolInput: { command: "echo installing; npm install express" },
+ params: { allowed: ["bun"] },
+ });
+ expect((await policy.fn(ctx)).decision).toBe("deny");
+ });
+ });
+
describe("warn-background-process", () => {
const policy = BUILTIN_POLICIES.find((p) => p.name === "warn-background-process")!;
diff --git a/docs/built-in-policies.mdx b/docs/built-in-policies.mdx
index 978d08ea..6055465b 100644
--- a/docs/built-in-policies.mdx
+++ b/docs/built-in-policies.mdx
@@ -21,6 +21,7 @@ Policies are grouped into categories:
| [Git](#git) | block-push-master, block-work-on-main, block-force-push, warn-git-amend, warn-git-stash-drop, warn-all-files-staged | PreToolUse |
| [Database](#database) | warn-destructive-sql, warn-schema-alteration | PreToolUse |
| [Warnings](#warnings) | warn-large-file-write, warn-package-publish, warn-background-process, warn-global-package-install | PreToolUse |
+| [Package managers](#package-managers) | prefer-package-manager | PreToolUse |
| [Workflow](#workflow) | require-commit-before-stop, require-push-before-stop, require-pr-before-stop, require-ci-green-before-stop | Stop |
- **`block-`** — stop the agent from proceeding.
@@ -436,6 +437,42 @@ No parameters.
---
+## Package managers
+
+Enforce which package managers the agent is allowed to use.
+
+### `prefer-package-manager`
+
+**Event:** PreToolUse (Bash)
+**Default:** Disabled. When enabled, blocks any package manager command not in the `allowed` list and tells Claude to rewrite the command using an allowed manager.
+
+Detects: pip, pip3, python -m pip, npm, npx, yarn, pnpm, pnpx, bun, bunx, uv, poetry, pipenv, conda, cargo.
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `allowed` | string[] | `[]` | Allowed package manager names. Any detected manager not in this list is blocked. When empty, the policy is a no-op. |
+| `blocked` | string[] | `[]` | Additional manager names to block beyond the built-in list (e.g. `['pdm', 'pipx']`). |
+
+The built-in block list covers: pip, pip3, npm, npx, yarn, pnpm, pnpx, bun, bunx, uv, poetry, pipenv, conda, cargo. Use `blocked` to append managers not in this list.
+
+**Example configuration:**
+
+```json
+{
+ "enabledPolicies": ["prefer-package-manager"],
+ "policyParams": {
+ "prefer-package-manager": {
+ "allowed": ["uv", "bun"],
+ "blocked": ["pdm", "pipx"]
+ }
+ }
+}
+```
+
+With this config, `pip install flask` and `pdm install flask` are both denied with a message telling Claude to use `uv` or `bun` instead. Commands like `uv pip install flask` are allowed because `uv` is in the allowlist and is checked first.
+
+---
+
## AI behavior
Detect when agents get stuck or behave unexpectedly.
diff --git a/docs/custom-policies.mdx b/docs/custom-policies.mdx
index 4da05cc2..d94ce68b 100644
--- a/docs/custom-policies.mdx
+++ b/docs/custom-policies.mdx
@@ -60,7 +60,7 @@ Drop `*policies.{js,mjs,ts}` files into `.failproofai/policies/` and they're aut
- Works alongside explicit `--custom` and built-in policies
-Convention policies are the easiest way to share policies across a team. Commit `.failproofai/policies/` to git and every team member gets them automatically.
+Convention policies are the easiest way to build a quality standard for your org. Commit `.failproofai/policies/` to git and every team member gets the same rules automatically — no per-developer setup needed. As your team discovers new failure modes, add a policy and push. Over time these become a living quality standard that keeps improving with every contribution.
### Option 2: Explicit file path
diff --git a/docs/examples.mdx b/docs/examples.mdx
index 6ba65adf..46d02471 100644
--- a/docs/examples.mdx
+++ b/docs/examples.mdx
@@ -242,6 +242,60 @@ Every team member who has failproofai installed will automatically pick up these
---
+## Build an org-wide quality standard with convention policies
+
+The most impactful setup: commit `.failproofai/policies/` to your repo with policies tailored to your project. Every team member gets them automatically — no install commands, no config changes.
+
+
+
+ ```bash
+ mkdir -p .failproofai/policies
+ ```
+
+ ```js
+ // .failproofai/policies/team-policies.mjs
+ import { customPolicies, allow, deny, instruct } from "failproofai";
+
+ // Enforce your team's preferred package manager
+ // (or enable the built-in prefer-package-manager policy instead)
+ customPolicies.add({
+ name: "enforce-bun",
+ match: { events: ["PreToolUse"] },
+ fn: async (ctx) => {
+ if (ctx.toolName !== "Bash") return allow();
+ const cmd = String(ctx.toolInput?.command ?? "");
+ if (/\bnpm\b/.test(cmd)) return deny("Use bun instead of npm.");
+ return allow();
+ },
+ });
+
+ // Remind the agent to run tests before committing
+ customPolicies.add({
+ name: "test-before-commit",
+ match: { events: ["PreToolUse"] },
+ fn: async (ctx) => {
+ if (ctx.toolName !== "Bash") return allow();
+ if (/git\s+commit/.test(ctx.toolInput?.command ?? "")) {
+ return instruct("Run tests before committing.");
+ }
+ return allow();
+ },
+ });
+ ```
+
+
+ ```bash
+ git add .failproofai/policies/
+ git commit -m "Add team quality policies"
+ ```
+
+
+ As your team hits new failure modes, add policies and push. Everyone gets the update on their next `git pull`. These policies become a living quality standard that grows with your team.
+
+
+
+---
+
## More examples
The [`examples/`](https://github.com/exospherehost/failproofai/tree/main/examples) directory in the repo contains:
diff --git a/docs/getting-started.mdx b/docs/getting-started.mdx
index 926c3642..9094116a 100644
--- a/docs/getting-started.mdx
+++ b/docs/getting-started.mdx
@@ -87,6 +87,58 @@ Policies run in your local process. Nothing is sent to a remote service.
---
+## Set up team policies with convention-based policies
+
+The fastest way to establish quality standards across your team is the `.failproofai/policies/` convention. Drop policy files into this directory and they're loaded automatically — no flags, no config changes, no install commands.
+
+
+
+ ```bash
+ mkdir -p .failproofai/policies
+ ```
+
+
+ Copy the starter examples or write your own:
+
+ ```bash
+ cp node_modules/failproofai/examples/convention-policies/*.mjs .failproofai/policies/
+ ```
+
+ Or create a new one:
+
+ ```js
+ // .failproofai/policies/team-policies.mjs
+ import { customPolicies, allow, deny, instruct } from "failproofai";
+
+ customPolicies.add({
+ name: "test-before-commit",
+ match: { events: ["PreToolUse"] },
+ fn: async (ctx) => {
+ if (ctx.toolName !== "Bash") return allow();
+ if (/git\s+commit/.test(ctx.toolInput?.command ?? "")) {
+ return instruct("Run tests before committing.");
+ }
+ return allow();
+ },
+ });
+ ```
+
+
+ ```bash
+ git add .failproofai/policies/
+ git commit -m "Add team quality policies"
+ ```
+
+ Every team member who has failproofai installed picks up these policies automatically. No per-developer setup needed.
+
+
+
+
+Commit `.failproofai/policies/` to your repo so the whole team shares the same standards. As your team discovers new failure modes, add policies and push — everyone gets the update on their next `git pull`. Over time these policies become a living quality standard that keeps improving.
+
+
+---
+
## Data storage
All configuration and logs stay on your machine:
diff --git a/src/hooks/builtin-policies.ts b/src/hooks/builtin-policies.ts
index 9fdea588..bdd55ee3 100644
--- a/src/hooks/builtin-policies.ts
+++ b/src/hooks/builtin-policies.ts
@@ -138,6 +138,20 @@ const BUN_GLOBAL_RE = /\bbun\s+(?:install|add)\b(?=.*(?:\s-g\b|--global\b))/;
const CARGO_INSTALL_RE = /\bcargo\s+install\b/;
const PIP_SYSTEM_RE = /\bpip(?:3)?\s+install\b(?=.*(?:--user\b|--break-system-packages\b))/;
+// preferPackageManager — maps manager name → detection patterns
+const PKG_MANAGER_DETECTORS: Record = {
+ pip: [/\bpip\b/, /\bpip3\b/, /\bpython3?\s+-m\s+pip\b/],
+ npm: [/\bnpm\b/, /\bnpx\b/],
+ yarn: [/\byarn\b/],
+ pnpm: [/\bpnpm\b/, /\bpnpx\b/],
+ bun: [/\bbun\b/, /\bbunx\b/],
+ uv: [/\buv\b/],
+ poetry: [/\bpoetry\b/],
+ pipenv: [/\bpipenv\b/],
+ conda: [/\bconda\b/],
+ cargo: [/\bcargo\b/],
+};
+
// warnBackgroundProcess
const NOHUP_RE = /\bnohup\s+\S/;
const SCREEN_DETACH_RE = /\bscreen\s+-[A-Za-z]*d[A-Za-z]*\b/;
@@ -857,6 +871,73 @@ function warnGlobalPackageInstall(ctx: PolicyContext): PolicyResult {
return allow();
}
+// Split a compound shell command into independent segments.
+const SEGMENT_SPLIT_RE = /\s*(?:&&|\|\||\||;)\s*/;
+
+function preferPackageManager(ctx: PolicyContext): PolicyResult {
+ if (ctx.toolName !== "Bash") return allow();
+ const cmd = getCommand(ctx);
+ if (!cmd) return allow();
+
+ const allowed = (ctx.params?.allowed ?? []) as string[];
+ if (allowed.length === 0) return allow();
+
+ const allowedSet = new Set(allowed.map((a) => a.toLowerCase()));
+ const blocked = (ctx.params?.blocked ?? []) as string[];
+ const allowedList = allowed.join(", ");
+
+ // Evaluate each shell segment independently so that
+ // "uv --version && pip install flask" correctly denies the pip segment.
+ const segments = cmd.split(SEGMENT_SPLIT_RE);
+
+ for (const segment of segments) {
+ const trimmed = segment.trim();
+ if (!trimmed) continue;
+
+ // Check if this segment uses an allowed manager — if so, skip it.
+ let segmentAllowed = false;
+ for (const manager of allowedSet) {
+ const patterns = PKG_MANAGER_DETECTORS[manager];
+ if (!patterns) continue;
+ for (const pattern of patterns) {
+ if (pattern.test(trimmed)) { segmentAllowed = true; break; }
+ }
+ if (segmentAllowed) break;
+ }
+ if (segmentAllowed) continue;
+
+ // Check if this segment uses a non-allowed builtin manager.
+ for (const [manager, patterns] of Object.entries(PKG_MANAGER_DETECTORS)) {
+ if (allowedSet.has(manager)) continue;
+ for (const pattern of patterns) {
+ if (pattern.test(trimmed)) {
+ return deny(
+ `"${manager}" is not an allowed package manager. ` +
+ `Allowed package managers for this project: ${allowedList}. ` +
+ `Rewrite this command using an allowed package manager.`,
+ );
+ }
+ }
+ }
+
+ // Check user-specified blocked managers.
+ for (const name of blocked) {
+ const lower = name.toLowerCase();
+ if (allowedSet.has(lower)) continue;
+ const re = new RegExp(`\\b${lower.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
+ if (re.test(trimmed)) {
+ return deny(
+ `"${lower}" is not an allowed package manager. ` +
+ `Allowed package managers for this project: ${allowedList}. ` +
+ `Rewrite this command using an allowed package manager.`,
+ );
+ }
+ }
+ }
+
+ return allow();
+}
+
function warnBackgroundProcess(ctx: PolicyContext): PolicyResult {
if (ctx.toolName !== "Bash") return allow();
const cmd = getCommand(ctx);
@@ -1409,6 +1490,26 @@ export const BUILTIN_POLICIES: BuiltinPolicyDefinition[] = [
defaultEnabled: false,
category: "Packages & System",
},
+ {
+ name: "prefer-package-manager",
+ description: "Blocks non-preferred package managers and tells Claude to use an allowed one (e.g., uv instead of pip)",
+ fn: preferPackageManager,
+ match: { events: ["PreToolUse"], toolNames: ["Bash"] },
+ defaultEnabled: false,
+ category: "Packages & System",
+ params: {
+ allowed: {
+ type: "string[]",
+ description: "Allowed package manager names (e.g. ['uv', 'bun']). Any detected manager not in this list is blocked.",
+ default: [],
+ },
+ blocked: {
+ type: "string[]",
+ description: "Additional manager names to block beyond the built-in list (e.g. ['pdm', 'pipx']).",
+ default: [],
+ },
+ } satisfies PolicyParamsSchema,
+ },
{
name: "warn-large-file-write",
description: "Warn before writing files larger than 1MB (configurable via thresholdKb param)",