Files
cline--cline/sdk/examples/plugins/gitignore-read-files-guard.ts
2026-07-13 12:52:40 +08:00

275 lines
6.4 KiB
TypeScript

/**
* Gitignore Read Guard Plugin Example
*
* Blocks file-opening tool calls when any requested path is ignored by a
* workspace .gitignore file.
*
* CLI usage:
* cline plugin install https://github.com/cline/cline/blob/main/sdk/examples/plugins/gitignore-read-files-guard.ts --cwd .
* cline -i "Read the ignored .env file"
*/
import { spawn } from "node:child_process";
import { isAbsolute, relative, resolve, sep } from "node:path";
import type { AgentPlugin } from "@cline/core";
const FILE_ACCESS_TOOL_NAMES = new Set(["read_files", "editor", "apply_patch"]);
interface IgnoreMatch {
path: string;
source: string;
line: string;
pattern: string;
}
let workspaceRoot = process.cwd();
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}
function addPath(value: unknown, paths: string[]): void {
if (typeof value === "string" && value.trim().length > 0) {
paths.push(value);
return;
}
const record = asRecord(value);
if (typeof record?.path === "string" && record.path.trim().length > 0) {
paths.push(record.path);
}
}
function addPathList(value: unknown, paths: string[]): void {
if (Array.isArray(value)) {
for (const entry of value) {
addPath(entry, paths);
}
return;
}
addPath(value, paths);
}
function extractStructuredPaths(input: unknown): string[] {
const paths: string[] = [];
addPath(input, paths);
if (Array.isArray(input)) {
addPathList(input, paths);
return paths;
}
const record = asRecord(input);
if (!record) {
return paths;
}
addPathList(record.files, paths);
addPathList(record.file_paths, paths);
addPathList(record.paths, paths);
return paths;
}
function getApplyPatchInput(input: unknown): string | undefined {
if (typeof input === "string") {
return input;
}
const record = asRecord(input);
if (typeof record?.input === "string") {
return record.input;
}
return undefined;
}
function extractApplyPatchPaths(input: unknown): string[] {
const patch = getApplyPatchInput(input);
if (!patch) {
return [];
}
const paths: string[] = [];
const pathHeaders = [
"*** Add File: ",
"*** Update File: ",
"*** Delete File: ",
"*** Move to: ",
];
for (const line of patch.split(/\r?\n/)) {
for (const header of pathHeaders) {
if (line.startsWith(header)) {
const path = line.slice(header.length).trim();
if (path.length > 0) {
paths.push(path);
}
break;
}
}
}
return paths;
}
function extractRequestedPaths(toolName: string, input: unknown): string[] {
if (toolName === "apply_patch") {
return extractApplyPatchPaths(input);
}
return extractStructuredPaths(input);
}
function toWorkspaceRelativePath(path: string): string | undefined {
const absolutePath = resolve(workspaceRoot, path);
const relativePath = relative(workspaceRoot, absolutePath);
if (
relativePath.length === 0 ||
relativePath === ".." ||
relativePath.startsWith(`..${sep}`) ||
isAbsolute(relativePath) ||
resolve(workspaceRoot, relativePath) !== absolutePath
) {
return undefined;
}
return relativePath.split(sep).join("/");
}
function isWorkspaceGitignoreSource(source: string): boolean {
const normalizedSource = source.split(sep).join("/");
if (
normalizedSource !== ".gitignore" &&
!normalizedSource.endsWith("/.gitignore")
) {
return false;
}
const absoluteSource = resolve(workspaceRoot, source);
const relativeSource = relative(workspaceRoot, absoluteSource);
return (
relativeSource === ".gitignore" ||
(!relativeSource.startsWith(`..${sep}`) &&
!isAbsolute(relativeSource) &&
relativeSource.endsWith(`${sep}.gitignore`))
);
}
function parseCheckIgnoreOutput(output: Buffer): IgnoreMatch[] {
const fields = output.toString("utf8").split("\0");
const matches: IgnoreMatch[] = [];
for (let i = 0; i + 3 < fields.length; i += 4) {
const source = fields[i] ?? "";
const line = fields[i + 1] ?? "";
const pattern = fields[i + 2] ?? "";
const path = fields[i + 3] ?? "";
if (!source || !pattern || pattern.startsWith("!")) {
continue;
}
if (!line || !path) {
continue;
}
if (!isWorkspaceGitignoreSource(source)) {
continue;
}
matches.push({ source, line, pattern, path });
}
return matches;
}
async function checkIgnoredByWorkspaceGitignore(
relativePaths: string[],
): Promise<IgnoreMatch[]> {
if (relativePaths.length === 0) {
return [];
}
return new Promise((resolveMatches) => {
const child = spawn(
"git",
["check-ignore", "--stdin", "-z", "-v", "-n", "--no-index"],
{
cwd: workspaceRoot,
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
},
);
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
child.on("error", (error) => {
console.warn(
`[gitignore-read-guard] could not run git: ${error.message}`,
);
resolveMatches([]);
});
child.on("close", (code) => {
if (code !== 0 && code !== 1) {
const message = Buffer.concat(stderr).toString("utf8").trim();
console.warn(
`[gitignore-read-guard] git check-ignore failed${message ? `: ${message}` : ""}`,
);
resolveMatches([]);
return;
}
resolveMatches(parseCheckIgnoreOutput(Buffer.concat(stdout)));
});
child.stdin.end(`${relativePaths.join("\0")}\0`);
});
}
const plugin: AgentPlugin = {
name: "gitignore-read-files-guard",
manifest: {
capabilities: ["hooks"],
},
setup(_api, ctx) {
workspaceRoot = ctx.workspaceInfo?.rootPath ?? process.cwd();
},
hooks: {
async beforeTool({ toolCall, input }) {
if (!FILE_ACCESS_TOOL_NAMES.has(toolCall.toolName)) {
return undefined;
}
const relativePaths = [
...new Set(
extractRequestedPaths(toolCall.toolName, input)
.map(toWorkspaceRelativePath)
.filter((path): path is string => Boolean(path)),
),
];
const ignored = await checkIgnoredByWorkspaceGitignore(relativePaths);
if (ignored.length === 0) {
return undefined;
}
const blockedPaths = ignored.map((match) => match.path).join(", ");
console.error(
`[gitignore-read-guard] blocked ${toolCall.toolName}: ${blockedPaths}`,
);
return {
skip: true,
reason: `Blocked ${toolCall.toolName}: ${blockedPaths} matched workspace .gitignore`,
};
},
},
};
export { plugin };
export default plugin;