pilotignore 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -0
- package/LICENSE +7 -7
- package/README.md +155 -152
- package/bin/cli.js +269 -89
- package/package.json +19 -5
- package/src/pilotignore.cjs +516 -0
- package/src/templates/default.pilotignore +15 -0
- package/{templates → src/templates}/pilotignore.json +16 -15
- package/templates/default.pilotignore +0 -6
- package/templates/pilotignore.cjs +0 -178
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const os = require("os");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
|
|
5
|
+
// Helper function for logging
|
|
6
|
+
function appendLog(cwd, message) {
|
|
7
|
+
try {
|
|
8
|
+
const logDir = path.join(cwd, ".copilot", "logs");
|
|
9
|
+
const logFile = path.join(logDir, "pilotignore.log");
|
|
10
|
+
|
|
11
|
+
// Ensure the log directory exists
|
|
12
|
+
if (!fs.existsSync(logDir)) {
|
|
13
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const timestamp = new Date().toISOString();
|
|
17
|
+
fs.appendFileSync(logFile, `[${timestamp}] ${message}\n`);
|
|
18
|
+
} catch (e) {
|
|
19
|
+
// If logging fails, do not crash the entire program
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// --- .pilotignore rule engine (gitignore-style patterns) ---
|
|
24
|
+
|
|
25
|
+
function escapeRegExp(s) {
|
|
26
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Translates one glob pattern (without leading `!` or trailing `/`)
|
|
30
|
+
// into a regex source string. Supports *, **, ? and [char] classes.
|
|
31
|
+
function globToRegExpSource(glob) {
|
|
32
|
+
let re = "";
|
|
33
|
+
let i = 0;
|
|
34
|
+
while (i < glob.length) {
|
|
35
|
+
const c = glob[i];
|
|
36
|
+
if (c === "*") {
|
|
37
|
+
if (glob[i + 1] === "*") {
|
|
38
|
+
if (glob[i + 2] === "/") {
|
|
39
|
+
re += "(?:[^/]+/)*";
|
|
40
|
+
i += 3;
|
|
41
|
+
} else if (i + 2 === glob.length && i > 0 && glob[i - 1] === "/") {
|
|
42
|
+
// trailing `/**`: fold the already-emitted `/` into the group
|
|
43
|
+
// so `a/**` matches both `a` and everything under it
|
|
44
|
+
re = re.slice(0, -1) + "(?:/.*)?";
|
|
45
|
+
i += 2;
|
|
46
|
+
} else {
|
|
47
|
+
re += ".*";
|
|
48
|
+
i += 2;
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
re += "[^/]*";
|
|
52
|
+
i += 1;
|
|
53
|
+
}
|
|
54
|
+
} else if (c === "?") {
|
|
55
|
+
re += "[^/]";
|
|
56
|
+
i += 1;
|
|
57
|
+
} else if (c === "[") {
|
|
58
|
+
const end = glob.indexOf("]", i + 1);
|
|
59
|
+
if (end === -1) {
|
|
60
|
+
re += "\\[";
|
|
61
|
+
i += 1;
|
|
62
|
+
} else {
|
|
63
|
+
let cls = glob.slice(i + 1, end);
|
|
64
|
+
if (cls.startsWith("!")) cls = "^" + cls.slice(1);
|
|
65
|
+
re += "[" + cls.replace(/\\/g, "\\\\") + "]";
|
|
66
|
+
i = end + 1;
|
|
67
|
+
}
|
|
68
|
+
} else if (c === "\\") {
|
|
69
|
+
if (i + 1 < glob.length) {
|
|
70
|
+
re += escapeRegExp(glob[i + 1]);
|
|
71
|
+
i += 2;
|
|
72
|
+
} else {
|
|
73
|
+
re += "\\\\";
|
|
74
|
+
i += 1;
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
re += escapeRegExp(c);
|
|
78
|
+
i += 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return re;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Parses a single .pilotignore line into a rule object, or null.
|
|
85
|
+
function compileRule(rawLine) {
|
|
86
|
+
let pattern = rawLine.trim();
|
|
87
|
+
if (!pattern || pattern.startsWith("#")) return null;
|
|
88
|
+
|
|
89
|
+
let negated = false;
|
|
90
|
+
if (pattern.startsWith("!")) {
|
|
91
|
+
negated = true;
|
|
92
|
+
pattern = pattern.slice(1);
|
|
93
|
+
}
|
|
94
|
+
pattern = pattern.replace(/^\\!/, "!").replace(/^\\#/, "#");
|
|
95
|
+
|
|
96
|
+
let dirOnly = false;
|
|
97
|
+
if (pattern.endsWith("/")) {
|
|
98
|
+
dirOnly = true;
|
|
99
|
+
pattern = pattern.slice(0, -1);
|
|
100
|
+
}
|
|
101
|
+
if (!pattern) return null;
|
|
102
|
+
|
|
103
|
+
// A `/` anywhere inside the pattern anchors it to the project root.
|
|
104
|
+
const anchored = pattern.includes("/");
|
|
105
|
+
const body = globToRegExpSource(pattern.replace(/^\/+/, ""));
|
|
106
|
+
const prefix = anchored ? "^" : "^(?:.+/)?";
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
raw: rawLine.trim(),
|
|
110
|
+
pattern: pattern.replace(/^\/+/, ""),
|
|
111
|
+
anchored,
|
|
112
|
+
negated,
|
|
113
|
+
dirOnly,
|
|
114
|
+
re: new RegExp(prefix + body + "(?:/.*)?$", "i"),
|
|
115
|
+
// Matches only when the pattern consumes a directory strictly inside
|
|
116
|
+
// the candidate path (used for `dir/` rules).
|
|
117
|
+
insideRe: dirOnly
|
|
118
|
+
? new RegExp(prefix + body + "/.+$", "i")
|
|
119
|
+
: null,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function loadRules(ignoreFilePath) {
|
|
124
|
+
const rules = [];
|
|
125
|
+
if (!fs.existsSync(ignoreFilePath)) return rules;
|
|
126
|
+
const content = fs.readFileSync(ignoreFilePath, "utf8");
|
|
127
|
+
content.split(/\r?\n/).forEach((line) => {
|
|
128
|
+
const rule = compileRule(line);
|
|
129
|
+
if (rule) rules.push(rule);
|
|
130
|
+
});
|
|
131
|
+
return rules;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Last matching rule wins; `!` rules un-ignore (gitignore semantics).
|
|
135
|
+
function matchRules(rules, cwd, absolutePath, relative) {
|
|
136
|
+
let blocked = false;
|
|
137
|
+
let hit = null;
|
|
138
|
+
for (const rule of rules) {
|
|
139
|
+
let matches = rule.re.test(relative);
|
|
140
|
+
if (matches && rule.dirOnly) {
|
|
141
|
+
if (!rule.insideRe.test(relative)) {
|
|
142
|
+
// The candidate *is* the directory pattern target itself; only
|
|
143
|
+
// match when it actually resolves to a directory.
|
|
144
|
+
let isDir = false;
|
|
145
|
+
try {
|
|
146
|
+
isDir = fs.statSync(absolutePath).isDirectory();
|
|
147
|
+
} catch (e) {
|
|
148
|
+
isDir = true; // fail closed on unresolvable paths
|
|
149
|
+
}
|
|
150
|
+
matches = isDir;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (matches) {
|
|
154
|
+
blocked = !rule.negated;
|
|
155
|
+
hit = rule;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { blocked, rule: hit };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// --- filesystem-aware checks (shell globs, directory operations) ---
|
|
162
|
+
|
|
163
|
+
const GLOB_CHARS = /[*?[]/;
|
|
164
|
+
// Upper bound on directory entries visited per walk, so a hook call on a
|
|
165
|
+
// huge tree stays well inside Copilot's hook timeout.
|
|
166
|
+
const WALK_LIMIT = 5000;
|
|
167
|
+
|
|
168
|
+
// Iterative directory walk. `visit` returning true stops the walk early.
|
|
169
|
+
// Symlinked directories are not followed.
|
|
170
|
+
function walk(dir, maxDepth, visit) {
|
|
171
|
+
let seen = 0;
|
|
172
|
+
const stack = [{ dir, rel: "", depth: 0 }];
|
|
173
|
+
while (stack.length) {
|
|
174
|
+
const cur = stack.pop();
|
|
175
|
+
let entries;
|
|
176
|
+
try {
|
|
177
|
+
entries = fs.readdirSync(cur.dir, { withFileTypes: true });
|
|
178
|
+
} catch (e) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
for (const entry of entries) {
|
|
182
|
+
if (++seen > WALK_LIMIT) return "truncated";
|
|
183
|
+
const entryAbs = path.join(cur.dir, entry.name);
|
|
184
|
+
const entryRel = cur.rel ? cur.rel + "/" + entry.name : entry.name;
|
|
185
|
+
if (visit(entryAbs, entryRel) === true) return "found";
|
|
186
|
+
if (entry.isDirectory() && cur.depth + 1 < maxDepth) {
|
|
187
|
+
stack.push({ dir: entryAbs, rel: entryRel, depth: cur.depth + 1 });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return "done";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Expands a shell-style glob token (`shop*`, `*.tx?`, `[s]ecret`) into the
|
|
195
|
+
// paths it would match on disk, so `cat shop*` is judged by what the shell
|
|
196
|
+
// would actually pass to `cat`. Dotfiles are matched too (conservative).
|
|
197
|
+
function expandGlob(cwd, token) {
|
|
198
|
+
const abs = resolveCandidate(cwd, token).replace(/\\/g, "/");
|
|
199
|
+
const segments = abs.split("/");
|
|
200
|
+
const firstGlob = segments.findIndex((s) => GLOB_CHARS.test(s));
|
|
201
|
+
if (firstGlob === -1) return [];
|
|
202
|
+
const base = segments.slice(0, firstGlob).join("/") || "/";
|
|
203
|
+
const rest = segments.slice(firstGlob).join("/");
|
|
204
|
+
const re = new RegExp("^" + globToRegExpSource(rest) + "$", "i");
|
|
205
|
+
const maxDepth = rest.includes("**")
|
|
206
|
+
? Infinity
|
|
207
|
+
: segments.length - firstGlob;
|
|
208
|
+
const matches = [];
|
|
209
|
+
walk(base, maxDepth, (entryAbs, entryRel) => {
|
|
210
|
+
if (re.test(entryRel)) matches.push(entryAbs);
|
|
211
|
+
});
|
|
212
|
+
return matches;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Returns { path, rule } when `relative` is a directory that contains a
|
|
216
|
+
// blocked path, so `rm -rf .github` cannot remove the firewall wholesale.
|
|
217
|
+
function findBlockedDescendant(rules, cwd, absolutePath, relative) {
|
|
218
|
+
const dirPrefix = relative.toLowerCase() + "/";
|
|
219
|
+
|
|
220
|
+
// 1. Anchored rules: blocked even if the protected file doesn't exist.
|
|
221
|
+
for (const rule of rules) {
|
|
222
|
+
if (rule.negated || !rule.anchored) continue;
|
|
223
|
+
const segs = rule.pattern.split("/");
|
|
224
|
+
const idx = segs.findIndex((s) => GLOB_CHARS.test(s));
|
|
225
|
+
const literal = (idx === -1 ? segs : segs.slice(0, idx)).join("/");
|
|
226
|
+
if (literal.toLowerCase().startsWith(dirPrefix)) {
|
|
227
|
+
return { path: rule.pattern, rule };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 2. Any rule: scan what actually exists under the directory.
|
|
232
|
+
let isDir = false;
|
|
233
|
+
try {
|
|
234
|
+
isDir = fs.statSync(absolutePath).isDirectory();
|
|
235
|
+
} catch (e) {}
|
|
236
|
+
if (!isDir) return null;
|
|
237
|
+
|
|
238
|
+
let hit = null;
|
|
239
|
+
const status = walk(absolutePath, Infinity, (entryAbs, entryRel) => {
|
|
240
|
+
const rel = relative + "/" + entryRel;
|
|
241
|
+
const { blocked, rule } = matchRules(rules, cwd, entryAbs, rel);
|
|
242
|
+
if (blocked) {
|
|
243
|
+
hit = { path: rel, rule };
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
if (status === "truncated") {
|
|
248
|
+
appendLog(
|
|
249
|
+
cwd,
|
|
250
|
+
`Directory scan of '${relative}' stopped after ${WALK_LIMIT} entries.`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
return hit;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// --- candidate extraction ---
|
|
257
|
+
|
|
258
|
+
// Tool input fields whose string values are shell-like commands and
|
|
259
|
+
// therefore deserve full tokenization.
|
|
260
|
+
const COMMAND_KEYS = new Set([
|
|
261
|
+
"command",
|
|
262
|
+
"cmd",
|
|
263
|
+
"script",
|
|
264
|
+
"shell",
|
|
265
|
+
"shellcommand",
|
|
266
|
+
"commandline",
|
|
267
|
+
"bash",
|
|
268
|
+
"powershell",
|
|
269
|
+
"sh",
|
|
270
|
+
"input",
|
|
271
|
+
"args",
|
|
272
|
+
"arguments",
|
|
273
|
+
]);
|
|
274
|
+
|
|
275
|
+
function tokenize(str) {
|
|
276
|
+
return str
|
|
277
|
+
.split(/[\s'"`|&;(){}<>]+/)
|
|
278
|
+
.flatMap((t) => t.split("="))
|
|
279
|
+
.map((t) => t.trim())
|
|
280
|
+
.filter((t) => t && !t.startsWith("-") && !t.includes("://"));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function isPathLike(tok) {
|
|
284
|
+
return (
|
|
285
|
+
tok.includes("/") ||
|
|
286
|
+
tok.includes("\\") ||
|
|
287
|
+
tok.startsWith(".") ||
|
|
288
|
+
tok.startsWith("~")
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function collectCandidates(payload) {
|
|
293
|
+
const candidates = [];
|
|
294
|
+
|
|
295
|
+
function add(str, isCommand) {
|
|
296
|
+
candidates.push(str);
|
|
297
|
+
for (const tok of tokenize(str)) {
|
|
298
|
+
if (isCommand || isPathLike(tok)) candidates.push(tok);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function collect(val, isCommand) {
|
|
303
|
+
if (!val) return;
|
|
304
|
+
if (typeof val === "string") {
|
|
305
|
+
try {
|
|
306
|
+
const parsed = JSON.parse(val);
|
|
307
|
+
if (parsed && typeof parsed === "object") {
|
|
308
|
+
collect(parsed, isCommand);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
} catch (e) {}
|
|
312
|
+
add(val, isCommand);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (Array.isArray(val)) {
|
|
316
|
+
val.forEach((v) => collect(v, isCommand));
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (typeof val === "object") {
|
|
320
|
+
for (const [k, v] of Object.entries(val)) {
|
|
321
|
+
const keyIsCommand =
|
|
322
|
+
isCommand || COMMAND_KEYS.has(k.toLowerCase().replace(/[_-]/g, ""));
|
|
323
|
+
collect(v, keyIsCommand);
|
|
324
|
+
}
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
candidates.push(String(val));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (payload.tool_input) collect(payload.tool_input, false);
|
|
331
|
+
if (
|
|
332
|
+
payload.tool_input_as_string &&
|
|
333
|
+
typeof payload.tool_input_as_string === "string"
|
|
334
|
+
) {
|
|
335
|
+
try {
|
|
336
|
+
collect(JSON.parse(payload.tool_input_as_string), false);
|
|
337
|
+
} catch (e) {}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (payload.toolArgs) collect(payload.toolArgs, false);
|
|
341
|
+
if (payload.arguments) collect(payload.arguments, false);
|
|
342
|
+
|
|
343
|
+
if (candidates.length === 0) {
|
|
344
|
+
collect(payload, false);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
[
|
|
348
|
+
"path",
|
|
349
|
+
"file",
|
|
350
|
+
"target",
|
|
351
|
+
"filePath",
|
|
352
|
+
"args",
|
|
353
|
+
"files",
|
|
354
|
+
"targetPath",
|
|
355
|
+
"file_path",
|
|
356
|
+
"command",
|
|
357
|
+
"commandLine",
|
|
358
|
+
].forEach((k) =>
|
|
359
|
+
collect(payload[k], COMMAND_KEYS.has(k.toLowerCase().replace(/[_-]/g, ""))),
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
return candidates;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function resolveCandidate(cwd, candidate) {
|
|
366
|
+
const c = candidate.trim().replace(/\\/g, "/");
|
|
367
|
+
if (c === "~" || c.startsWith("~/")) {
|
|
368
|
+
return path.join(os.homedir(), c.slice(1));
|
|
369
|
+
}
|
|
370
|
+
return path.resolve(cwd, c);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function isIdeClient(payload, rawInput) {
|
|
374
|
+
const fields = [
|
|
375
|
+
payload.client,
|
|
376
|
+
payload.clientName,
|
|
377
|
+
payload.client_name,
|
|
378
|
+
payload.ide,
|
|
379
|
+
payload.host,
|
|
380
|
+
payload.source,
|
|
381
|
+
];
|
|
382
|
+
for (const f of fields) {
|
|
383
|
+
if (typeof f === "string" && /vscode|copilot[-_ ]?chat|\bide\b/i.test(f)) {
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return rawInput.includes("vscode") || rawInput.includes("copilot-chat");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Emits a deny in both supported shapes: flat fields for the native
|
|
391
|
+
// Copilot CLI format and the hookSpecificOutput wrapper for the
|
|
392
|
+
// VS Code/Claude-compatible format. Extra fields are ignored per spec.
|
|
393
|
+
// There is deliberately no "allow" counterpart: allowing means emitting
|
|
394
|
+
// nothing, so Copilot's own permission flow (approval prompts, tool
|
|
395
|
+
// rules) still applies. pilotignore only ever narrows access.
|
|
396
|
+
function deny(payload, reason) {
|
|
397
|
+
console.log(
|
|
398
|
+
JSON.stringify({
|
|
399
|
+
permissionDecision: "deny",
|
|
400
|
+
permissionDecisionReason: reason,
|
|
401
|
+
hookSpecificOutput: {
|
|
402
|
+
hookEventName: "PreToolUse",
|
|
403
|
+
permissionDecision: "deny",
|
|
404
|
+
permissionDecisionReason: reason,
|
|
405
|
+
},
|
|
406
|
+
}),
|
|
407
|
+
);
|
|
408
|
+
console.error(reason);
|
|
409
|
+
if (isIdeClient(payload, input)) {
|
|
410
|
+
process.exit(0); // copilot chat VS Code IDE
|
|
411
|
+
} else {
|
|
412
|
+
process.exit(2); // copilot CLI
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
let input = "";
|
|
417
|
+
process.stdin.setEncoding("utf8");
|
|
418
|
+
process.stdin.on("data", (chunk) => {
|
|
419
|
+
input += chunk;
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
process.stdin.on("end", () => {
|
|
423
|
+
// Default cwd, updated if a better one is found in the payload
|
|
424
|
+
let currentCwd = process.cwd();
|
|
425
|
+
appendLog(currentCwd, `HOOK PROCESS STARTED (pid: ${process.pid})`);
|
|
426
|
+
|
|
427
|
+
let payload = {};
|
|
428
|
+
try {
|
|
429
|
+
if (!input.trim()) throw new Error("empty hook payload");
|
|
430
|
+
payload = JSON.parse(input);
|
|
431
|
+
currentCwd = payload.cwd || currentCwd; // Use the project root
|
|
432
|
+
|
|
433
|
+
const candidates = [];
|
|
434
|
+
for (const c of collectCandidates(payload)) {
|
|
435
|
+
if (typeof c !== "string" || !c.trim()) continue;
|
|
436
|
+
candidates.push(c);
|
|
437
|
+
// Single-token globs are expanded the way the shell would
|
|
438
|
+
if (GLOB_CHARS.test(c) && !/\s/.test(c.trim())) {
|
|
439
|
+
candidates.push(...expandGlob(currentCwd, c.trim()));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
appendLog(currentCwd, `Candidate paths: ${JSON.stringify(candidates)}`);
|
|
444
|
+
|
|
445
|
+
const ignoreFilePath = path.join(currentCwd, ".pilotignore");
|
|
446
|
+
const ignoreRules = loadRules(ignoreFilePath);
|
|
447
|
+
if (ignoreRules.length > 0) {
|
|
448
|
+
appendLog(
|
|
449
|
+
currentCwd,
|
|
450
|
+
`Loaded ${ignoreRules.length} rules from the .pilotignore file.`,
|
|
451
|
+
);
|
|
452
|
+
} else {
|
|
453
|
+
appendLog(
|
|
454
|
+
currentCwd,
|
|
455
|
+
`Warning: no rules loaded from ${ignoreFilePath}`,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
for (const candidate of candidates) {
|
|
460
|
+
const absolutePath = resolveCandidate(currentCwd, candidate);
|
|
461
|
+
const relative = path
|
|
462
|
+
.relative(currentCwd, absolutePath)
|
|
463
|
+
.replace(/\\/g, "/");
|
|
464
|
+
// The project root itself is exempt, otherwise `ls .` and every
|
|
465
|
+
// other root-level command would be denied.
|
|
466
|
+
if (!relative || relative === "." || relative === "..") continue;
|
|
467
|
+
|
|
468
|
+
const { blocked, rule } = matchRules(
|
|
469
|
+
ignoreRules,
|
|
470
|
+
currentCwd,
|
|
471
|
+
absolutePath,
|
|
472
|
+
relative,
|
|
473
|
+
);
|
|
474
|
+
if (blocked) {
|
|
475
|
+
appendLog(
|
|
476
|
+
currentCwd,
|
|
477
|
+
`DECISION: DENY (Blocked) - ${relative} matched rule '${rule.raw}'.`,
|
|
478
|
+
);
|
|
479
|
+
deny(
|
|
480
|
+
payload,
|
|
481
|
+
`Access to '${relative}' is blocked by .pilotignore rule '${rule.raw}'.`,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (!relative.startsWith("../")) {
|
|
486
|
+
const hit = findBlockedDescendant(
|
|
487
|
+
ignoreRules,
|
|
488
|
+
currentCwd,
|
|
489
|
+
absolutePath,
|
|
490
|
+
relative,
|
|
491
|
+
);
|
|
492
|
+
if (hit) {
|
|
493
|
+
appendLog(
|
|
494
|
+
currentCwd,
|
|
495
|
+
`DECISION: DENY (Blocked) - ${relative} contains ${hit.path} (rule '${hit.rule.raw}').`,
|
|
496
|
+
);
|
|
497
|
+
deny(
|
|
498
|
+
payload,
|
|
499
|
+
`Access to '${relative}' is blocked: it contains '${hit.path}', protected by .pilotignore rule '${hit.rule.raw}'.`,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
appendLog(currentCwd, `DECISION: ALLOW (no output, default permission flow)`);
|
|
506
|
+
process.exit(0);
|
|
507
|
+
} catch (err) {
|
|
508
|
+
// Fail closed, matching Copilot's own handling of crashing preToolUse
|
|
509
|
+
// command hooks: if the call can't be evaluated, it doesn't run.
|
|
510
|
+
appendLog(currentCwd, `ERROR: ${err.message} - DECISION: DENY (fail-closed)`);
|
|
511
|
+
deny(
|
|
512
|
+
payload,
|
|
513
|
+
`pilotignore could not evaluate this tool call (${err.message}); denying by default (fail-closed).`,
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Add files here to hide them from GitHub Copilot
|
|
2
|
+
# Supports gitignore-style patterns:
|
|
3
|
+
# .env exact file, matched at any depth
|
|
4
|
+
# *.pem wildcard: any file ending in .pem
|
|
5
|
+
# .env* .env, .env.local, .env.production, ...
|
|
6
|
+
# secrets/ everything under any secrets/ directory
|
|
7
|
+
# /config.json anchored to the project root only
|
|
8
|
+
# secrets/** everything under root secrets/
|
|
9
|
+
# !secrets/public.txt negation: allow an exception
|
|
10
|
+
# Protect the blocklist itself — prevents the agent from reading
|
|
11
|
+
# the ruleset or disabling protection by editing/removing it
|
|
12
|
+
.pilotignore
|
|
13
|
+
# Protect the firewall files for the same reason
|
|
14
|
+
.github/hooks/pilotignore.cjs
|
|
15
|
+
.github/hooks/pilotignore.json
|
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 1,
|
|
3
|
-
"hooks": {
|
|
4
|
-
"preToolUse": [
|
|
5
|
-
{
|
|
6
|
-
"type": "command",
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"hooks": {
|
|
4
|
+
"preToolUse": [
|
|
5
|
+
{
|
|
6
|
+
"type": "command",
|
|
7
|
+
"command": "node .github/hooks/pilotignore.cjs",
|
|
8
|
+
"bash": "node .github/hooks/pilotignore.cjs",
|
|
9
|
+
"powershell": "node .github\\hooks\\pilotignore.cjs",
|
|
10
|
+
"windows": "node .github\\hooks\\pilotignore.cjs",
|
|
11
|
+
"cwd": ".",
|
|
12
|
+
"timeoutSec": 5
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
}
|