@melihmucuk/leash 1.0.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.
@@ -0,0 +1,517 @@
1
+ // packages/core/command-analyzer.ts
2
+ import { basename, resolve as resolve2 } from "path";
3
+ import { homedir as homedir2 } from "os";
4
+
5
+ // packages/core/path-validator.ts
6
+ import { resolve, relative } from "path";
7
+ import { homedir } from "os";
8
+ import { realpathSync } from "fs";
9
+
10
+ // packages/core/constants.ts
11
+ var DANGEROUS_COMMANDS = /* @__PURE__ */ new Set([
12
+ "rm",
13
+ "rmdir",
14
+ "unlink",
15
+ "shred",
16
+ "mv",
17
+ "cp",
18
+ "chmod",
19
+ "chown",
20
+ "chgrp",
21
+ "truncate",
22
+ "dd",
23
+ "ln"
24
+ ]);
25
+ var DANGEROUS_PATTERNS = [
26
+ { pattern: /\bfind\b.*\s-delete\b/, name: "find -delete" },
27
+ { pattern: /\bfind\b.*-exec\s+(rm|mv|cp)\b/, name: "find -exec" },
28
+ { pattern: /\bxargs\s+(-[^\s]+\s+)*(rm|mv|cp)\b/, name: "xargs" },
29
+ { pattern: /\brsync\b.*--delete\b/, name: "rsync --delete" }
30
+ ];
31
+ var REDIRECT_PATTERN = />{1,2}\s*(?:"([^"]+)"|'([^']+)'|([^\s;|&>]+))/g;
32
+ var DEVICE_PATHS = ["/dev/null", "/dev/stdin", "/dev/stdout", "/dev/stderr"];
33
+ var TEMP_PATHS = [
34
+ "/tmp",
35
+ "/var/tmp",
36
+ "/private/tmp",
37
+ "/private/var/tmp"
38
+ ];
39
+ var SAFE_WRITE_PATHS = [...DEVICE_PATHS, ...TEMP_PATHS];
40
+ var PROTECTED_PATTERNS = [
41
+ { pattern: /^\.env($|\.(?!example$).+)/, name: ".env files" },
42
+ { pattern: /^\.git(\/|$)/, name: ".git directory" }
43
+ ];
44
+ var DANGEROUS_GIT_PATTERNS = [
45
+ { pattern: /\bgit\s+checkout\b.*\s--\s/, name: "git checkout --" },
46
+ { pattern: /\bgit\s+restore\s+(?!--staged)/, name: "git restore" },
47
+ { pattern: /\bgit\s+reset\s+.*--hard\b/, name: "git reset --hard" },
48
+ { pattern: /\bgit\s+reset\s+.*--merge\b/, name: "git reset --merge" },
49
+ {
50
+ pattern: /\bgit\s+clean\s+.*(-[a-zA-Z]*f[a-zA-Z]*|--force)\b/,
51
+ name: "git clean --force"
52
+ },
53
+ { pattern: /\bgit\s+push\s+.*(-f|--force)\b/, name: "git push --force" },
54
+ { pattern: /\bgit\s+branch\s+.*-D\b/, name: "git branch -D" },
55
+ { pattern: /\bgit\s+stash\s+drop\b/, name: "git stash drop" },
56
+ { pattern: /\bgit\s+stash\s+clear\b/, name: "git stash clear" }
57
+ ];
58
+
59
+ // packages/core/path-validator.ts
60
+ var PathValidator = class {
61
+ constructor(workingDirectory) {
62
+ this.workingDirectory = workingDirectory;
63
+ }
64
+ expand(path) {
65
+ return path.replace(/^~(?=\/|$)/, homedir()).replace(/\$\{?(\w+)\}?/g, (_, name) => {
66
+ if (name === "HOME") return homedir();
67
+ if (name === "PWD") return this.workingDirectory;
68
+ return process.env[name] || "";
69
+ });
70
+ }
71
+ resolveReal(path) {
72
+ const expanded = this.expand(path);
73
+ const resolved = resolve(this.workingDirectory, expanded);
74
+ try {
75
+ return realpathSync(resolved);
76
+ } catch {
77
+ return resolved;
78
+ }
79
+ }
80
+ isWithinWorkingDir(path) {
81
+ try {
82
+ const realPath = this.resolveReal(path);
83
+ const realWorkDir = realpathSync(this.workingDirectory);
84
+ if (realPath === realWorkDir) {
85
+ return true;
86
+ }
87
+ const rel = relative(realWorkDir, realPath);
88
+ return !!rel && !rel.startsWith("..") && !rel.startsWith("/");
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+ matchesAny(resolved, paths) {
94
+ return paths.some((p) => resolved === p || resolved.startsWith(p + "/"));
95
+ }
96
+ isSafeForWrite(path) {
97
+ const resolved = this.resolveReal(path);
98
+ return this.matchesAny(resolved, SAFE_WRITE_PATHS);
99
+ }
100
+ isTempPath(path) {
101
+ const resolved = this.resolveReal(path);
102
+ return this.matchesAny(resolved, TEMP_PATHS);
103
+ }
104
+ isProtectedPath(path) {
105
+ if (!this.isWithinWorkingDir(path)) {
106
+ return { protected: false };
107
+ }
108
+ const realPath = this.resolveReal(path);
109
+ const realWorkDir = realpathSync(this.workingDirectory);
110
+ const relativePath = relative(realWorkDir, realPath);
111
+ for (const { pattern, name } of PROTECTED_PATTERNS) {
112
+ if (pattern.test(relativePath)) {
113
+ return { protected: true, name };
114
+ }
115
+ }
116
+ return { protected: false };
117
+ }
118
+ };
119
+
120
+ // packages/core/command-analyzer.ts
121
+ var DELETE_COMMANDS = /* @__PURE__ */ new Set(["rm", "rmdir", "unlink", "shred"]);
122
+ var CommandAnalyzer = class {
123
+ constructor(workingDirectory) {
124
+ this.workingDirectory = workingDirectory;
125
+ this.pathValidator = new PathValidator(workingDirectory);
126
+ }
127
+ pathValidator;
128
+ resolvePath(path, resolveBase) {
129
+ const expanded = this.pathValidator.expand(path);
130
+ return resolveBase ? resolve2(resolveBase, expanded) : expanded;
131
+ }
132
+ isPathAllowed(path, allowDevicePaths, resolveBase) {
133
+ const resolved = this.resolvePath(path, resolveBase);
134
+ if (this.pathValidator.isWithinWorkingDir(resolved)) return true;
135
+ return allowDevicePaths ? this.pathValidator.isSafeForWrite(resolved) : this.pathValidator.isTempPath(resolved);
136
+ }
137
+ checkProtectedPath(path, context, resolveBase) {
138
+ const resolved = this.resolvePath(path, resolveBase);
139
+ const protection = this.pathValidator.isProtectedPath(resolved);
140
+ if (protection.protected) {
141
+ return {
142
+ blocked: true,
143
+ reason: `${context} targets protected path: ${protection.name}`
144
+ };
145
+ }
146
+ return { blocked: false };
147
+ }
148
+ extractPaths(command) {
149
+ const paths = [];
150
+ const quoted = command.match(/["']([^"']+)["']/g) || [];
151
+ quoted.forEach((q) => paths.push(q.slice(1, -1)));
152
+ const tokens = command.replace(/["'][^"']*["']/g, "").split(/\s+/).filter((t) => !t.startsWith("-"));
153
+ for (let i = 1; i < tokens.length; i++) {
154
+ const value = tokens[i].includes("=") ? tokens[i].split("=").slice(1).join("=") : tokens[i];
155
+ if (value) paths.push(value);
156
+ }
157
+ return paths;
158
+ }
159
+ getBaseCommand(command) {
160
+ const tokens = command.trim().split(/\s+/);
161
+ if (tokens.length === 0) return "";
162
+ const first = tokens[0];
163
+ let i = 0;
164
+ if (first === "sudo" || first === "command") {
165
+ i++;
166
+ while (i < tokens.length) {
167
+ const token = tokens[i];
168
+ if (token === "--") {
169
+ i++;
170
+ break;
171
+ }
172
+ if (token.startsWith("-")) {
173
+ i++;
174
+ continue;
175
+ }
176
+ break;
177
+ }
178
+ return basename(tokens[i] || "");
179
+ }
180
+ if (first === "env") {
181
+ const optsWithArgs = /* @__PURE__ */ new Set([
182
+ "-u",
183
+ "-C",
184
+ "-S",
185
+ "--unset",
186
+ "--chdir",
187
+ "--split-string"
188
+ ]);
189
+ i++;
190
+ while (i < tokens.length) {
191
+ const token = tokens[i];
192
+ if (token === "--") {
193
+ i++;
194
+ break;
195
+ }
196
+ if (optsWithArgs.has(token)) {
197
+ i += 2;
198
+ continue;
199
+ }
200
+ if (token.startsWith("-") || token.includes("=")) {
201
+ i++;
202
+ continue;
203
+ }
204
+ break;
205
+ }
206
+ return basename(tokens[i] || "");
207
+ }
208
+ return basename(tokens[0] || "");
209
+ }
210
+ splitCommands(command) {
211
+ const commands = [];
212
+ let current = "";
213
+ let inSingleQuote = false;
214
+ let inDoubleQuote = false;
215
+ let i = 0;
216
+ while (i < command.length) {
217
+ const char = command[i];
218
+ const nextChar = command[i + 1];
219
+ if (char === "\\" && !inSingleQuote) {
220
+ current += char + (nextChar || "");
221
+ i += 2;
222
+ continue;
223
+ }
224
+ if (char === "'" && !inDoubleQuote) {
225
+ inSingleQuote = !inSingleQuote;
226
+ current += char;
227
+ i++;
228
+ continue;
229
+ }
230
+ if (char === '"' && !inSingleQuote) {
231
+ inDoubleQuote = !inDoubleQuote;
232
+ current += char;
233
+ i++;
234
+ continue;
235
+ }
236
+ if (!inSingleQuote && !inDoubleQuote) {
237
+ if (char === "&" && nextChar === "&" || char === "|" && nextChar === "|") {
238
+ if (current.trim()) commands.push(current.trim());
239
+ current = "";
240
+ i += 2;
241
+ continue;
242
+ }
243
+ if (char === ";" || char === "|" && nextChar !== "|") {
244
+ if (current.trim()) commands.push(current.trim());
245
+ current = "";
246
+ i++;
247
+ continue;
248
+ }
249
+ }
250
+ current += char;
251
+ i++;
252
+ }
253
+ if (current.trim()) commands.push(current.trim());
254
+ return commands;
255
+ }
256
+ checkRedirects(command) {
257
+ const matches = command.matchAll(REDIRECT_PATTERN);
258
+ for (const match of matches) {
259
+ const path = match[1] || match[2] || match[3];
260
+ if (!path || path.startsWith("&")) {
261
+ continue;
262
+ }
263
+ if (!this.pathValidator.isSafeForWrite(path) && !this.pathValidator.isWithinWorkingDir(path)) {
264
+ return {
265
+ blocked: true,
266
+ reason: `Redirect to path outside working directory: ${path}`
267
+ };
268
+ }
269
+ const result = this.checkProtectedPath(path, "Redirect");
270
+ if (result.blocked) return result;
271
+ }
272
+ return { blocked: false };
273
+ }
274
+ isCdCommand(command) {
275
+ return this.getBaseCommand(command) === "cd";
276
+ }
277
+ extractCdTarget(command) {
278
+ const trimmed = command.trim();
279
+ const quotedMatch = trimmed.match(/^cd\s+["']([^"']+)["']/);
280
+ if (quotedMatch) return quotedMatch[1];
281
+ const tokens = trimmed.split(/\s+/);
282
+ if (tokens[0] !== "cd") return null;
283
+ if (tokens.length === 1) return homedir2();
284
+ let i = 1;
285
+ while (i < tokens.length && tokens[i].startsWith("-")) {
286
+ i++;
287
+ }
288
+ return tokens[i] || null;
289
+ }
290
+ checkDangerousGitCommands(command) {
291
+ for (const { pattern, name } of DANGEROUS_GIT_PATTERNS) {
292
+ if (pattern.test(command)) {
293
+ return {
294
+ blocked: true,
295
+ reason: `Dangerous git command blocked: ${name}`
296
+ };
297
+ }
298
+ }
299
+ return { blocked: false };
300
+ }
301
+ checkDangerousPatterns(command, resolveBase) {
302
+ for (const { pattern, name } of DANGEROUS_PATTERNS) {
303
+ if (!pattern.test(command)) continue;
304
+ const paths = this.extractPaths(command);
305
+ for (const path of paths) {
306
+ const resolved = this.resolvePath(path, resolveBase);
307
+ if (!this.pathValidator.isWithinWorkingDir(resolved) && !this.pathValidator.isTempPath(resolved)) {
308
+ return {
309
+ blocked: true,
310
+ reason: `Command "${name}" targets path outside working directory: ${path}`
311
+ };
312
+ }
313
+ const result = this.checkProtectedPath(path, `Command "${name}"`, resolveBase);
314
+ if (result.blocked) return result;
315
+ }
316
+ }
317
+ return { blocked: false };
318
+ }
319
+ checkCpCommand(paths, resolveBase) {
320
+ if (paths.length === 0) return { blocked: false };
321
+ const dest = paths[paths.length - 1];
322
+ if (!this.isPathAllowed(dest, true, resolveBase)) {
323
+ return {
324
+ blocked: true,
325
+ reason: `Command "cp" targets path outside working directory: ${this.resolvePath(dest, resolveBase)}`
326
+ };
327
+ }
328
+ return this.checkProtectedPath(dest, 'Command "cp"', resolveBase);
329
+ }
330
+ checkDdCommand(command, resolveBase) {
331
+ const ofMatch = command.match(/\bof=["']?([^"'\s]+)["']?/);
332
+ if (!ofMatch) return { blocked: false };
333
+ const dest = ofMatch[1];
334
+ if (!this.isPathAllowed(dest, true, resolveBase)) {
335
+ return {
336
+ blocked: true,
337
+ reason: `Command "dd" targets path outside working directory: ${this.resolvePath(dest, resolveBase)}`
338
+ };
339
+ }
340
+ return this.checkProtectedPath(dest, 'Command "dd"', resolveBase);
341
+ }
342
+ checkMvCommand(paths, resolveBase) {
343
+ if (paths.length === 0) return { blocked: false };
344
+ const dest = paths[paths.length - 1];
345
+ const sources = paths.slice(0, -1);
346
+ if (!this.isPathAllowed(dest, true, resolveBase)) {
347
+ return {
348
+ blocked: true,
349
+ reason: `Command "mv" targets path outside working directory: ${this.resolvePath(dest, resolveBase)}`
350
+ };
351
+ }
352
+ const destResult = this.checkProtectedPath(
353
+ dest,
354
+ 'Command "mv"',
355
+ resolveBase
356
+ );
357
+ if (destResult.blocked) return destResult;
358
+ for (const source of sources) {
359
+ if (!this.isPathAllowed(source, false, resolveBase)) {
360
+ return {
361
+ blocked: true,
362
+ reason: `Command "mv" targets path outside working directory: ${this.resolvePath(source, resolveBase)}`
363
+ };
364
+ }
365
+ const sourceResult = this.checkProtectedPath(
366
+ source,
367
+ 'Command "mv"',
368
+ resolveBase
369
+ );
370
+ if (sourceResult.blocked) return sourceResult;
371
+ }
372
+ return { blocked: false };
373
+ }
374
+ checkDeleteCommand(baseCmd, paths, resolveBase) {
375
+ for (const path of paths) {
376
+ if (!this.isPathAllowed(path, false, resolveBase)) {
377
+ return {
378
+ blocked: true,
379
+ reason: `Command "${baseCmd}" targets path outside working directory: ${this.resolvePath(path, resolveBase)}`
380
+ };
381
+ }
382
+ const result = this.checkProtectedPath(
383
+ path,
384
+ `Command "${baseCmd}"`,
385
+ resolveBase
386
+ );
387
+ if (result.blocked) return result;
388
+ }
389
+ return { blocked: false };
390
+ }
391
+ checkWriteCommand(baseCmd, paths, resolveBase) {
392
+ const allowDevicePaths = baseCmd === "truncate";
393
+ for (const path of paths) {
394
+ if (!this.isPathAllowed(path, allowDevicePaths, resolveBase)) {
395
+ return {
396
+ blocked: true,
397
+ reason: `Command "${baseCmd}" targets path outside working directory: ${this.resolvePath(path, resolveBase)}`
398
+ };
399
+ }
400
+ const result = this.checkProtectedPath(
401
+ path,
402
+ `Command "${baseCmd}"`,
403
+ resolveBase
404
+ );
405
+ if (result.blocked) return result;
406
+ }
407
+ return { blocked: false };
408
+ }
409
+ checkDangerousCommand(command, resolveBase) {
410
+ const baseCmd = this.getBaseCommand(command);
411
+ if (!DANGEROUS_COMMANDS.has(baseCmd)) {
412
+ return { blocked: false };
413
+ }
414
+ const paths = this.extractPaths(command);
415
+ if (baseCmd === "cp") return this.checkCpCommand(paths, resolveBase);
416
+ if (baseCmd === "dd") return this.checkDdCommand(command, resolveBase);
417
+ if (baseCmd === "mv") return this.checkMvCommand(paths, resolveBase);
418
+ if (DELETE_COMMANDS.has(baseCmd))
419
+ return this.checkDeleteCommand(baseCmd, paths, resolveBase);
420
+ return this.checkWriteCommand(baseCmd, paths, resolveBase);
421
+ }
422
+ analyze(command) {
423
+ const gitResult = this.checkDangerousGitCommands(command);
424
+ if (gitResult.blocked) return gitResult;
425
+ const redirectResult = this.checkRedirects(command);
426
+ if (redirectResult.blocked) return redirectResult;
427
+ const commands = this.splitCommands(command);
428
+ const hasCd = commands.some((cmd) => this.isCdCommand(cmd.trim()));
429
+ if (!hasCd) {
430
+ const patternResult = this.checkDangerousPatterns(command);
431
+ if (patternResult.blocked) return patternResult;
432
+ }
433
+ let currentWorkDir = this.workingDirectory;
434
+ for (const cmd of commands) {
435
+ const trimmed = cmd.trim();
436
+ if (!trimmed) continue;
437
+ if (this.isCdCommand(trimmed)) {
438
+ const target = this.extractCdTarget(trimmed);
439
+ if (target) {
440
+ const expanded = this.pathValidator.expand(target);
441
+ currentWorkDir = resolve2(currentWorkDir, expanded);
442
+ }
443
+ continue;
444
+ }
445
+ const resolveBase = currentWorkDir !== this.workingDirectory ? currentWorkDir : void 0;
446
+ if (hasCd) {
447
+ const patternResult = this.checkDangerousPatterns(trimmed, resolveBase);
448
+ if (patternResult.blocked) return patternResult;
449
+ }
450
+ const result = this.checkDangerousCommand(trimmed, resolveBase);
451
+ if (result.blocked) return result;
452
+ }
453
+ return { blocked: false };
454
+ }
455
+ validatePath(path) {
456
+ if (!path) return { blocked: false };
457
+ if (!this.pathValidator.isSafeForWrite(path) && !this.pathValidator.isWithinWorkingDir(path)) {
458
+ return {
459
+ blocked: true,
460
+ reason: `File operation targets path outside working directory: ${path}`
461
+ };
462
+ }
463
+ return this.checkProtectedPath(path, "File operation");
464
+ }
465
+ };
466
+
467
+ // packages/pi/leash.ts
468
+ function leash_default(pi) {
469
+ let analyzer = null;
470
+ pi.on("session", async (event, ctx) => {
471
+ if (event.reason === "start") {
472
+ analyzer = new CommandAnalyzer(ctx.cwd);
473
+ ctx.ui.notify("\u{1F512} Leash active", "info");
474
+ }
475
+ });
476
+ pi.on("tool_call", async (event, ctx) => {
477
+ if (!analyzer) {
478
+ analyzer = new CommandAnalyzer(ctx.cwd);
479
+ }
480
+ if (event.toolName === "bash") {
481
+ const command = event.input.command || "";
482
+ const result = analyzer.analyze(command);
483
+ if (result.blocked) {
484
+ if (ctx.hasUI) {
485
+ ctx.ui.notify(`\u{1F6AB} Command blocked: ${result.reason}`, "warning");
486
+ }
487
+ return {
488
+ block: true,
489
+ reason: `Command blocked: ${command}
490
+ Reason: ${result.reason}
491
+ Working directory: ${ctx.cwd}
492
+ Action: Guide the user to run the command manually.`
493
+ };
494
+ }
495
+ }
496
+ if (event.toolName === "write" || event.toolName === "edit") {
497
+ const path = event.input.path || "";
498
+ const result = analyzer.validatePath(path);
499
+ if (result.blocked) {
500
+ if (ctx.hasUI) {
501
+ ctx.ui.notify(`\u{1F6AB} File operation blocked: ${result.reason}`, "warning");
502
+ }
503
+ return {
504
+ block: true,
505
+ reason: `File operation blocked: ${path}
506
+ Reason: ${result.reason}
507
+ Working directory: ${ctx.cwd}
508
+ Action: Guide the user to perform this operation manually.`
509
+ };
510
+ }
511
+ }
512
+ return void 0;
513
+ });
514
+ }
515
+ export {
516
+ leash_default as default
517
+ };
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@melihmucuk/leash",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Security guardrails for AI coding agents",
6
+ "bin": {
7
+ "leash": "./bin/leash.js"
8
+ },
9
+ "files": [
10
+ "dist/",
11
+ "bin/",
12
+ "!bin/test/"
13
+ ],
14
+ "author": "Melih Mucuk",
15
+ "license": "MIT",
16
+ "keywords": [
17
+ "ai",
18
+ "security",
19
+ "sandbox",
20
+ "guardrails",
21
+ "coding-agent",
22
+ "claude",
23
+ "openai",
24
+ "anthropic",
25
+ "llm",
26
+ "cli",
27
+ "plugin",
28
+ "hooks",
29
+ "file-system",
30
+ "protection"
31
+ ],
32
+ "homepage": "https://github.com/melihmucuk/leash",
33
+ "bugs": {
34
+ "url": "https://github.com/melihmucuk/leash/issues"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "https://github.com/melihmucuk/leash.git"
39
+ },
40
+ "scripts": {
41
+ "test": "npm run build:core && node --test 'packages/core/test/*.test.js' 'bin/test/*.test.js'",
42
+ "typecheck": "tsc --noEmit",
43
+ "build": "npm run build:core && npm run build:opencode && npm run build:pi && npm run build:claude-code && npm run build:factory",
44
+ "build:core": "esbuild packages/core/*.ts --outdir=packages/core/lib --platform=node --format=esm",
45
+ "build:opencode": "esbuild packages/opencode/leash.ts --bundle --outfile=dist/opencode/leash.js --platform=node --format=esm --external:@opencode-ai/plugin",
46
+ "build:pi": "esbuild packages/pi/leash.ts --bundle --outfile=dist/pi/leash.js --platform=node --format=esm --external:@mariozechner/pi-coding-agent",
47
+ "build:claude-code": "esbuild packages/claude-code/leash.ts --bundle --outfile=dist/claude-code/leash.js --platform=node --format=esm",
48
+ "build:factory": "esbuild packages/factory/leash.ts --bundle --outfile=dist/factory/leash.js --platform=node --format=esm"
49
+ },
50
+ "devDependencies": {
51
+ "@mariozechner/pi-coding-agent": "^0.27.2",
52
+ "@opencode-ai/plugin": "^1.0.191",
53
+ "@types/node": "^22.19.3",
54
+ "esbuild": "^0.27.2",
55
+ "typescript": "^5.9.3"
56
+ }
57
+ }