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)",