veto-leash 0.1.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.
Files changed (135) hide show
  1. package/IMPLEMENTATION_PLAN.md +2194 -0
  2. package/LICENSE +201 -0
  3. package/README.md +260 -0
  4. package/dist/audit/index.d.ts +38 -0
  5. package/dist/audit/index.d.ts.map +1 -0
  6. package/dist/audit/index.js +132 -0
  7. package/dist/audit/index.js.map +1 -0
  8. package/dist/cli.d.ts +3 -0
  9. package/dist/cli.d.ts.map +1 -0
  10. package/dist/cli.js +406 -0
  11. package/dist/cli.js.map +1 -0
  12. package/dist/cloud/index.d.ts +40 -0
  13. package/dist/cloud/index.d.ts.map +1 -0
  14. package/dist/cloud/index.js +115 -0
  15. package/dist/cloud/index.js.map +1 -0
  16. package/dist/compiler/builtins.d.ts +6 -0
  17. package/dist/compiler/builtins.d.ts.map +1 -0
  18. package/dist/compiler/builtins.js +129 -0
  19. package/dist/compiler/builtins.js.map +1 -0
  20. package/dist/compiler/cache.d.ts +6 -0
  21. package/dist/compiler/cache.d.ts.map +1 -0
  22. package/dist/compiler/cache.js +49 -0
  23. package/dist/compiler/cache.js.map +1 -0
  24. package/dist/compiler/index.d.ts +3 -0
  25. package/dist/compiler/index.d.ts.map +1 -0
  26. package/dist/compiler/index.js +48 -0
  27. package/dist/compiler/index.js.map +1 -0
  28. package/dist/compiler/llm.d.ts +3 -0
  29. package/dist/compiler/llm.d.ts.map +1 -0
  30. package/dist/compiler/llm.js +69 -0
  31. package/dist/compiler/llm.js.map +1 -0
  32. package/dist/compiler/prompt.d.ts +2 -0
  33. package/dist/compiler/prompt.d.ts.map +1 -0
  34. package/dist/compiler/prompt.js +37 -0
  35. package/dist/compiler/prompt.js.map +1 -0
  36. package/dist/config/loader.d.ts +22 -0
  37. package/dist/config/loader.d.ts.map +1 -0
  38. package/dist/config/loader.js +100 -0
  39. package/dist/config/loader.js.map +1 -0
  40. package/dist/config/schema.d.ts +42 -0
  41. package/dist/config/schema.d.ts.map +1 -0
  42. package/dist/config/schema.js +93 -0
  43. package/dist/config/schema.js.map +1 -0
  44. package/dist/matcher.d.ts +22 -0
  45. package/dist/matcher.d.ts.map +1 -0
  46. package/dist/matcher.js +69 -0
  47. package/dist/matcher.js.map +1 -0
  48. package/dist/native/aider.d.ts +10 -0
  49. package/dist/native/aider.d.ts.map +1 -0
  50. package/dist/native/aider.js +120 -0
  51. package/dist/native/aider.js.map +1 -0
  52. package/dist/native/claude-code.d.ts +14 -0
  53. package/dist/native/claude-code.d.ts.map +1 -0
  54. package/dist/native/claude-code.js +273 -0
  55. package/dist/native/claude-code.js.map +1 -0
  56. package/dist/native/cursor.d.ts +11 -0
  57. package/dist/native/cursor.d.ts.map +1 -0
  58. package/dist/native/cursor.js +105 -0
  59. package/dist/native/cursor.js.map +1 -0
  60. package/dist/native/index.d.ts +35 -0
  61. package/dist/native/index.d.ts.map +1 -0
  62. package/dist/native/index.js +171 -0
  63. package/dist/native/index.js.map +1 -0
  64. package/dist/native/opencode.d.ts +22 -0
  65. package/dist/native/opencode.d.ts.map +1 -0
  66. package/dist/native/opencode.js +225 -0
  67. package/dist/native/opencode.js.map +1 -0
  68. package/dist/native/windsurf.d.ts +14 -0
  69. package/dist/native/windsurf.d.ts.map +1 -0
  70. package/dist/native/windsurf.js +198 -0
  71. package/dist/native/windsurf.js.map +1 -0
  72. package/dist/types.d.ts +38 -0
  73. package/dist/types.d.ts.map +1 -0
  74. package/dist/types.js +11 -0
  75. package/dist/types.js.map +1 -0
  76. package/dist/ui/colors.d.ts +21 -0
  77. package/dist/ui/colors.d.ts.map +1 -0
  78. package/dist/ui/colors.js +41 -0
  79. package/dist/ui/colors.js.map +1 -0
  80. package/dist/watchdog/index.d.ts +25 -0
  81. package/dist/watchdog/index.d.ts.map +1 -0
  82. package/dist/watchdog/index.js +57 -0
  83. package/dist/watchdog/index.js.map +1 -0
  84. package/dist/watchdog/restore.d.ts +16 -0
  85. package/dist/watchdog/restore.d.ts.map +1 -0
  86. package/dist/watchdog/restore.js +56 -0
  87. package/dist/watchdog/restore.js.map +1 -0
  88. package/dist/watchdog/snapshot.d.ts +38 -0
  89. package/dist/watchdog/snapshot.d.ts.map +1 -0
  90. package/dist/watchdog/snapshot.js +166 -0
  91. package/dist/watchdog/snapshot.js.map +1 -0
  92. package/dist/watchdog/watcher.d.ts +28 -0
  93. package/dist/watchdog/watcher.d.ts.map +1 -0
  94. package/dist/watchdog/watcher.js +117 -0
  95. package/dist/watchdog/watcher.js.map +1 -0
  96. package/dist/wrapper/daemon.d.ts +12 -0
  97. package/dist/wrapper/daemon.d.ts.map +1 -0
  98. package/dist/wrapper/daemon.js +103 -0
  99. package/dist/wrapper/daemon.js.map +1 -0
  100. package/dist/wrapper/shims.d.ts +4 -0
  101. package/dist/wrapper/shims.d.ts.map +1 -0
  102. package/dist/wrapper/shims.js +390 -0
  103. package/dist/wrapper/shims.js.map +1 -0
  104. package/dist/wrapper/spawn.d.ts +4 -0
  105. package/dist/wrapper/spawn.d.ts.map +1 -0
  106. package/dist/wrapper/spawn.js +35 -0
  107. package/dist/wrapper/spawn.js.map +1 -0
  108. package/package.json +46 -0
  109. package/src/audit/index.ts +172 -0
  110. package/src/cli.ts +503 -0
  111. package/src/cloud/index.ts +139 -0
  112. package/src/compiler/builtins.ts +137 -0
  113. package/src/compiler/cache.ts +51 -0
  114. package/src/compiler/index.ts +59 -0
  115. package/src/compiler/llm.ts +83 -0
  116. package/src/compiler/prompt.ts +37 -0
  117. package/src/config/loader.ts +126 -0
  118. package/src/config/schema.ts +136 -0
  119. package/src/matcher.ts +89 -0
  120. package/src/native/aider.ts +150 -0
  121. package/src/native/claude-code.ts +308 -0
  122. package/src/native/cursor.ts +131 -0
  123. package/src/native/index.ts +233 -0
  124. package/src/native/opencode.ts +310 -0
  125. package/src/native/windsurf.ts +231 -0
  126. package/src/types.ts +48 -0
  127. package/src/ui/colors.ts +50 -0
  128. package/src/watchdog/index.ts +82 -0
  129. package/src/watchdog/restore.ts +74 -0
  130. package/src/watchdog/snapshot.ts +209 -0
  131. package/src/watchdog/watcher.ts +150 -0
  132. package/src/wrapper/daemon.ts +133 -0
  133. package/src/wrapper/shims.ts +409 -0
  134. package/src/wrapper/spawn.ts +47 -0
  135. package/tsconfig.json +20 -0
package/src/matcher.ts ADDED
@@ -0,0 +1,89 @@
1
+ // src/matcher.ts
2
+
3
+ import micromatch from 'micromatch';
4
+ import type { Policy } from './types.js';
5
+
6
+ const { isMatch } = micromatch;
7
+
8
+ const MATCH_OPTIONS = {
9
+ basename: true, // *.test.ts matches src/foo.test.ts
10
+ dot: true, // Match dotfiles
11
+ nocase: true, // Case insensitive (important for Windows)
12
+ };
13
+
14
+ /**
15
+ * Normalize a path for cross-platform pattern matching.
16
+ * - Converts backslashes to forward slashes (Windows paths)
17
+ * - Removes trailing slashes
18
+ * - Normalizes . and .. segments
19
+ */
20
+ export function normalizePath(p: string): string {
21
+ // Convert Windows backslashes to forward slashes
22
+ let normalized = p.replace(/\\/g, '/');
23
+
24
+ // Remove trailing slash
25
+ if (normalized.endsWith('/') && normalized.length > 1) {
26
+ normalized = normalized.slice(0, -1);
27
+ }
28
+
29
+ // Simple normalization of . and ..
30
+ const parts = normalized.split('/');
31
+ const result: string[] = [];
32
+
33
+ for (const part of parts) {
34
+ if (part === '..') {
35
+ result.pop();
36
+ } else if (part !== '.' && part !== '') {
37
+ result.push(part);
38
+ }
39
+ }
40
+
41
+ // Preserve leading slash for absolute paths
42
+ if (normalized.startsWith('/')) {
43
+ return '/' + result.join('/');
44
+ }
45
+
46
+ return result.join('/') || '.';
47
+ }
48
+
49
+ /**
50
+ * Check if a target path is protected by the policy.
51
+ * Returns true if the target matches include patterns and doesn't match exclude patterns.
52
+ */
53
+ export function isProtected(target: string, policy: Policy): boolean {
54
+ // Normalize the target path for cross-platform matching
55
+ const normalizedTarget = normalizePath(target);
56
+
57
+ const matchesInclude = policy.include.some((p) =>
58
+ isMatch(normalizedTarget, p, MATCH_OPTIONS)
59
+ );
60
+
61
+ if (!matchesInclude) return false;
62
+
63
+ const matchesExclude = policy.exclude.some((p) =>
64
+ isMatch(normalizedTarget, p, MATCH_OPTIONS)
65
+ );
66
+
67
+ return !matchesExclude;
68
+ }
69
+
70
+ /**
71
+ * Get all files in a list that would be protected by the policy.
72
+ */
73
+ export function getProtectedFiles(files: string[], policy: Policy): string[] {
74
+ return files.filter((f) => isProtected(f, policy));
75
+ }
76
+
77
+ /**
78
+ * Get all files in a list that would be excluded (allowed) by the policy.
79
+ */
80
+ export function getExcludedFiles(files: string[], policy: Policy): string[] {
81
+ return files.filter((f) => {
82
+ const matchesInclude = policy.include.some((p) =>
83
+ isMatch(f, p, MATCH_OPTIONS)
84
+ );
85
+ if (!matchesInclude) return false;
86
+
87
+ return policy.exclude.some((p) => isMatch(f, p, MATCH_OPTIONS));
88
+ });
89
+ }
@@ -0,0 +1,150 @@
1
+ // src/native/aider.ts
2
+ // Aider integration via .aider.conf.yml
3
+ // Aider supports read-only files via the 'read' config option
4
+
5
+ import { existsSync, writeFileSync, readFileSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { homedir } from 'os';
8
+ import type { Policy } from '../types.js';
9
+ import { COLORS, SYMBOLS } from '../ui/colors.js';
10
+ import { glob } from 'glob';
11
+
12
+ const AIDER_CONFIG_FILE = '.aider.conf.yml';
13
+ const AIDER_GLOBAL_CONFIG = join(homedir(), '.aider.conf.yml');
14
+
15
+ /**
16
+ * Install veto-leash restrictions into Aider config
17
+ * Uses the 'read' option to mark files as read-only
18
+ */
19
+ export async function installAiderConfig(
20
+ target: 'project' | 'global' = 'project'
21
+ ): Promise<void> {
22
+ console.log(`\n${COLORS.info}Installing veto-leash for Aider (${target})...${COLORS.reset}\n`);
23
+
24
+ const policies = loadStoredPolicies();
25
+
26
+ if (policies.length === 0) {
27
+ console.log(`${COLORS.warning}${SYMBOLS.warning} No policies found. Add policies first:${COLORS.reset}`);
28
+ console.log(` ${COLORS.dim}leash add "protect .env"${COLORS.reset}\n`);
29
+ return;
30
+ }
31
+
32
+ const configPath = target === 'global' ? AIDER_GLOBAL_CONFIG : AIDER_CONFIG_FILE;
33
+
34
+ // Collect protected files based on policies
35
+ const protectedFiles = await collectProtectedFiles(policies);
36
+
37
+ if (protectedFiles.length === 0) {
38
+ console.log(`${COLORS.warning}${SYMBOLS.warning} No matching files found in current directory${COLORS.reset}`);
39
+ return;
40
+ }
41
+
42
+ // Generate YAML config
43
+ const yamlContent = generateAiderYaml(protectedFiles, configPath);
44
+ writeFileSync(configPath, yamlContent);
45
+
46
+ console.log(` ${COLORS.success}${SYMBOLS.success}${COLORS.reset} Updated: ${configPath}`);
47
+ console.log(` ${COLORS.dim}Protected ${protectedFiles.length} files as read-only${COLORS.reset}`);
48
+
49
+ console.log(`\n${COLORS.warning}${SYMBOLS.warning} Note: Aider 'read' makes files read-only, preventing modifications.${COLORS.reset}`);
50
+ console.log(`For delete protection, use wrapper mode:`);
51
+ console.log(` ${COLORS.dim}leash aider "<restriction>"${COLORS.reset}\n`);
52
+ }
53
+
54
+ async function collectProtectedFiles(policies: Policy[]): Promise<string[]> {
55
+ const files: string[] = [];
56
+
57
+ for (const policy of policies) {
58
+ // Only protect modify actions (read-only)
59
+ if (policy.action !== 'modify' && policy.action !== 'delete') continue;
60
+
61
+ for (const pattern of policy.include) {
62
+ const matches = await glob(pattern, {
63
+ dot: true,
64
+ nodir: true,
65
+ ignore: ['node_modules/**', '.git/**'],
66
+ });
67
+ files.push(...matches);
68
+ }
69
+ }
70
+
71
+ // Dedupe
72
+ return [...new Set(files)];
73
+ }
74
+
75
+ function generateAiderYaml(protectedFiles: string[], existingPath: string): string {
76
+ let existing = '';
77
+ let existingLines: string[] = [];
78
+
79
+ if (existsSync(existingPath)) {
80
+ existing = readFileSync(existingPath, 'utf-8');
81
+ existingLines = existing.split('\n');
82
+
83
+ // Remove existing veto-leash section
84
+ const startIdx = existingLines.findIndex(l => l.includes('# veto-leash'));
85
+ const endIdx = existingLines.findIndex((l, i) => i > startIdx && l.includes('# end veto-leash'));
86
+
87
+ if (startIdx !== -1 && endIdx !== -1) {
88
+ existingLines.splice(startIdx, endIdx - startIdx + 1);
89
+ }
90
+ }
91
+
92
+ // Add veto-leash section
93
+ const vetoSection = [
94
+ '# veto-leash protected files (read-only)',
95
+ 'read:',
96
+ ...protectedFiles.map(f => ` - ${f}`),
97
+ '# end veto-leash',
98
+ ];
99
+
100
+ return [...existingLines.filter(l => l.trim()), '', ...vetoSection].join('\n');
101
+ }
102
+
103
+ /**
104
+ * Uninstall veto-leash from Aider config
105
+ */
106
+ export async function uninstallAiderConfig(
107
+ target: 'project' | 'global' = 'project'
108
+ ): Promise<void> {
109
+ const configPath = target === 'global' ? AIDER_GLOBAL_CONFIG : AIDER_CONFIG_FILE;
110
+
111
+ if (!existsSync(configPath)) {
112
+ console.log(`${COLORS.dim}No Aider config found at ${configPath}${COLORS.reset}`);
113
+ return;
114
+ }
115
+
116
+ const content = readFileSync(configPath, 'utf-8');
117
+ const lines = content.split('\n');
118
+
119
+ const startIdx = lines.findIndex(l => l.includes('# veto-leash'));
120
+ const endIdx = lines.findIndex((l, i) => i > startIdx && l.includes('# end veto-leash'));
121
+
122
+ if (startIdx !== -1 && endIdx !== -1) {
123
+ lines.splice(startIdx, endIdx - startIdx + 1);
124
+ const updated = lines.filter(l => l.trim()).join('\n');
125
+
126
+ if (updated.trim()) {
127
+ writeFileSync(configPath, updated);
128
+ } else {
129
+ require('fs').unlinkSync(configPath);
130
+ }
131
+
132
+ console.log(`${COLORS.success}${SYMBOLS.success} Removed veto-leash from ${configPath}${COLORS.reset}`);
133
+ } else {
134
+ console.log(`${COLORS.dim}No veto-leash config found in ${configPath}${COLORS.reset}`);
135
+ }
136
+ }
137
+
138
+ function loadStoredPolicies(): Policy[] {
139
+ const policiesFile = join(homedir(), '.config', 'veto-leash', 'policies.json');
140
+
141
+ try {
142
+ if (existsSync(policiesFile)) {
143
+ const data = JSON.parse(readFileSync(policiesFile, 'utf-8'));
144
+ return data.policies?.map((p: { policy: Policy }) => p.policy) || [];
145
+ }
146
+ } catch {
147
+ // Ignore
148
+ }
149
+ return [];
150
+ }
@@ -0,0 +1,308 @@
1
+ // src/native/claude-code.ts
2
+ // Claude Code native hook integration
3
+ // Generates PreToolUse hooks that integrate directly with Claude Code's permission system
4
+
5
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { homedir } from 'os';
8
+ import type { Policy } from '../types.js';
9
+ import { COLORS, SYMBOLS } from '../ui/colors.js';
10
+
11
+ const CLAUDE_CONFIG_DIR = join(homedir(), '.claude');
12
+ const CLAUDE_HOOKS_DIR = join(CLAUDE_CONFIG_DIR, 'hooks', 'veto-leash');
13
+ const CLAUDE_SETTINGS_FILE = join(CLAUDE_CONFIG_DIR, 'settings.json');
14
+
15
+ interface ClaudeSettings {
16
+ hooks?: {
17
+ PreToolUse?: Array<{
18
+ matcher: string;
19
+ hooks: Array<{
20
+ type: string;
21
+ command: string;
22
+ }>;
23
+ }>;
24
+ };
25
+ [key: string]: unknown;
26
+ }
27
+
28
+ /**
29
+ * Install veto-leash as a Claude Code PreToolUse hook
30
+ */
31
+ export async function installClaudeCodeHook(): Promise<void> {
32
+ console.log(`\n${COLORS.info}Installing veto-leash for Claude Code...${COLORS.reset}\n`);
33
+
34
+ // Create hooks directory
35
+ mkdirSync(CLAUDE_HOOKS_DIR, { recursive: true });
36
+ mkdirSync(join(CLAUDE_HOOKS_DIR, 'policies'), { recursive: true });
37
+
38
+ // Write the validator script (Python for cross-platform compatibility)
39
+ const validatorPath = join(CLAUDE_HOOKS_DIR, 'validator.py');
40
+ writeFileSync(validatorPath, VALIDATOR_SCRIPT, { mode: 0o755 });
41
+ console.log(` ${COLORS.success}${SYMBOLS.success}${COLORS.reset} Created validator: ${validatorPath}`);
42
+
43
+ // Update Claude settings
44
+ let settings: ClaudeSettings = {};
45
+ if (existsSync(CLAUDE_SETTINGS_FILE)) {
46
+ try {
47
+ settings = JSON.parse(readFileSync(CLAUDE_SETTINGS_FILE, 'utf-8'));
48
+ } catch {
49
+ // Start fresh if parse fails
50
+ }
51
+ }
52
+
53
+ // Ensure hooks structure exists
54
+ if (!settings.hooks) {
55
+ settings.hooks = {};
56
+ }
57
+ if (!settings.hooks.PreToolUse) {
58
+ settings.hooks.PreToolUse = [];
59
+ }
60
+
61
+ // Check if veto-leash hook already exists
62
+ const existingIndex = settings.hooks.PreToolUse.findIndex(
63
+ (h) => h.hooks.some((cmd) => cmd.command.includes('veto-leash'))
64
+ );
65
+
66
+ const hookEntry = {
67
+ matcher: 'Bash|Write|Edit|MultiEdit',
68
+ hooks: [
69
+ {
70
+ type: 'command',
71
+ command: `python3 "${validatorPath}"`,
72
+ },
73
+ ],
74
+ };
75
+
76
+ if (existingIndex >= 0) {
77
+ settings.hooks.PreToolUse[existingIndex] = hookEntry;
78
+ console.log(` ${COLORS.success}${SYMBOLS.success}${COLORS.reset} Updated hook in settings`);
79
+ } else {
80
+ settings.hooks.PreToolUse.push(hookEntry);
81
+ console.log(` ${COLORS.success}${SYMBOLS.success}${COLORS.reset} Added hook to settings`);
82
+ }
83
+
84
+ // Write settings back
85
+ mkdirSync(CLAUDE_CONFIG_DIR, { recursive: true });
86
+ writeFileSync(CLAUDE_SETTINGS_FILE, JSON.stringify(settings, null, 2));
87
+ console.log(` ${COLORS.success}${SYMBOLS.success}${COLORS.reset} Updated: ${CLAUDE_SETTINGS_FILE}`);
88
+
89
+ console.log(`\n${COLORS.success}${SYMBOLS.success} veto-leash installed for Claude Code${COLORS.reset}\n`);
90
+ console.log(`To add a policy:`);
91
+ console.log(` ${COLORS.dim}leash add "don't delete test files"${COLORS.reset}\n`);
92
+ console.log(`To remove:`);
93
+ console.log(` ${COLORS.dim}leash uninstall cc${COLORS.reset}\n`);
94
+ }
95
+
96
+ /**
97
+ * Add a policy to Claude Code's veto-leash policies
98
+ */
99
+ export async function addClaudeCodePolicy(policy: Policy, name: string): Promise<void> {
100
+ const policiesDir = join(CLAUDE_HOOKS_DIR, 'policies');
101
+ mkdirSync(policiesDir, { recursive: true });
102
+
103
+ const policyFile = join(policiesDir, `${name}.json`);
104
+ writeFileSync(policyFile, JSON.stringify(policy, null, 2));
105
+ console.log(`${COLORS.success}${SYMBOLS.success}${COLORS.reset} Policy saved: ${policyFile}`);
106
+ }
107
+
108
+ /**
109
+ * Uninstall veto-leash from Claude Code
110
+ */
111
+ export async function uninstallClaudeCodeHook(): Promise<void> {
112
+ console.log(`\n${COLORS.info}Removing veto-leash from Claude Code...${COLORS.reset}\n`);
113
+
114
+ // Remove from settings
115
+ if (existsSync(CLAUDE_SETTINGS_FILE)) {
116
+ try {
117
+ const settings: ClaudeSettings = JSON.parse(
118
+ readFileSync(CLAUDE_SETTINGS_FILE, 'utf-8')
119
+ );
120
+
121
+ if (settings.hooks?.PreToolUse) {
122
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
123
+ (h) => !h.hooks.some((cmd) => cmd.command.includes('veto-leash'))
124
+ );
125
+ writeFileSync(CLAUDE_SETTINGS_FILE, JSON.stringify(settings, null, 2));
126
+ console.log(` ${COLORS.success}${SYMBOLS.success}${COLORS.reset} Removed hook from settings`);
127
+ }
128
+ } catch {
129
+ console.log(` ${COLORS.warning}${SYMBOLS.warning}${COLORS.reset} Could not parse settings file`);
130
+ }
131
+ }
132
+
133
+ console.log(`\n${COLORS.success}${SYMBOLS.success} veto-leash removed from Claude Code${COLORS.reset}\n`);
134
+ console.log(`${COLORS.dim}Note: Policy files in ${CLAUDE_HOOKS_DIR} were preserved.${COLORS.reset}\n`);
135
+ }
136
+
137
+ /**
138
+ * Python validator script that runs as a Claude Code PreToolUse hook.
139
+ * Uses Python for maximum cross-platform compatibility.
140
+ */
141
+ const VALIDATOR_SCRIPT = `#!/usr/bin/env python3
142
+ """
143
+ veto-leash validator for Claude Code PreToolUse hooks.
144
+ Checks tool inputs against configured policies.
145
+
146
+ Exit codes:
147
+ 0 = allow
148
+ 2 = block (stderr shown to Claude)
149
+ """
150
+
151
+ import json
152
+ import sys
153
+ import os
154
+ import re
155
+ from pathlib import Path
156
+ from fnmatch import fnmatch
157
+
158
+ POLICIES_DIR = Path(__file__).parent / "policies"
159
+
160
+ def load_policies():
161
+ """Load all policy files from the policies directory."""
162
+ policies = []
163
+ if POLICIES_DIR.exists():
164
+ for f in POLICIES_DIR.glob("*.json"):
165
+ try:
166
+ policies.append(json.loads(f.read_text()))
167
+ except:
168
+ pass
169
+ return policies
170
+
171
+ def normalize_path(p):
172
+ """Normalize path for pattern matching."""
173
+ # Convert to forward slashes for consistent matching
174
+ return str(p).replace("\\\\", "/")
175
+
176
+ def matches_pattern(target, pattern):
177
+ """Check if target matches a glob pattern."""
178
+ target = normalize_path(target)
179
+ pattern = normalize_path(pattern)
180
+
181
+ # Also try matching just the basename
182
+ basename = os.path.basename(target)
183
+
184
+ return fnmatch(target, pattern) or fnmatch(basename, pattern)
185
+
186
+ def is_protected(target, policy):
187
+ """Check if target is protected by policy."""
188
+ # Must match at least one include pattern
189
+ matches_include = any(
190
+ matches_pattern(target, p) for p in policy.get("include", [])
191
+ )
192
+ if not matches_include:
193
+ return False
194
+
195
+ # Must not match any exclude pattern
196
+ matches_exclude = any(
197
+ matches_pattern(target, p) for p in policy.get("exclude", [])
198
+ )
199
+
200
+ return not matches_exclude
201
+
202
+ def parse_bash_targets(command, action):
203
+ """Extract file targets from bash commands."""
204
+ targets = []
205
+
206
+ # rm command patterns
207
+ if action == "delete":
208
+ # Match: rm, rm -rf, rm -f, etc.
209
+ rm_match = re.search(r'\\brm\\s+(?:-[rfiv]+\\s+)*(.+)', command)
210
+ if rm_match:
211
+ args = rm_match.group(1)
212
+ # Split on spaces, filter out flags
213
+ for arg in args.split():
214
+ if not arg.startswith('-'):
215
+ targets.append(arg)
216
+
217
+ # git rm
218
+ git_rm_match = re.search(r'\\bgit\\s+rm\\s+(?:-[rf]+\\s+)*(.+)', command)
219
+ if git_rm_match:
220
+ for arg in git_rm_match.group(1).split():
221
+ if not arg.startswith('-'):
222
+ targets.append(arg)
223
+
224
+ # mv/cp for modify action
225
+ elif action == "modify":
226
+ mv_match = re.search(r'\\b(mv|cp)\\s+(?:-[a-z]+\\s+)*(.+)', command)
227
+ if mv_match:
228
+ args = mv_match.group(2).split()
229
+ # First non-flag arg is source
230
+ for arg in args:
231
+ if not arg.startswith('-'):
232
+ targets.append(arg)
233
+ break
234
+
235
+ return targets
236
+
237
+ def get_action_for_tool(tool_name):
238
+ """Map Claude Code tool names to veto-leash actions."""
239
+ mapping = {
240
+ "Bash": ["delete", "modify", "execute"],
241
+ "Write": ["modify"],
242
+ "Edit": ["modify"],
243
+ "MultiEdit": ["modify"],
244
+ "Read": ["read"],
245
+ }
246
+ return mapping.get(tool_name, [])
247
+
248
+ def main():
249
+ try:
250
+ input_data = json.load(sys.stdin)
251
+ except json.JSONDecodeError:
252
+ sys.exit(0) # Allow if can't parse
253
+
254
+ tool_name = input_data.get("tool_name", "")
255
+ tool_input = input_data.get("tool_input", {})
256
+ cwd = input_data.get("cwd", os.getcwd())
257
+
258
+ policies = load_policies()
259
+ if not policies:
260
+ sys.exit(0) # No policies = allow all
261
+
262
+ # Extract targets based on tool
263
+ targets = []
264
+
265
+ if tool_name == "Bash":
266
+ command = tool_input.get("command", "")
267
+ for policy in policies:
268
+ action = policy.get("action", "modify")
269
+ targets.extend(parse_bash_targets(command, action))
270
+
271
+ elif tool_name in ("Write", "Edit", "MultiEdit"):
272
+ file_path = tool_input.get("file_path", "")
273
+ if file_path:
274
+ targets.append(file_path)
275
+
276
+ if not targets:
277
+ sys.exit(0) # No targets = allow
278
+
279
+ # Check each target against policies
280
+ for target in targets:
281
+ # Make relative to cwd if absolute
282
+ try:
283
+ target_path = Path(target)
284
+ if not target_path.is_absolute():
285
+ target_path = Path(cwd) / target
286
+ rel_target = str(target_path.relative_to(cwd))
287
+ except:
288
+ rel_target = target
289
+
290
+ for policy in policies:
291
+ tool_actions = get_action_for_tool(tool_name)
292
+ policy_action = policy.get("action", "modify")
293
+
294
+ if policy_action in tool_actions:
295
+ if is_protected(rel_target, policy):
296
+ # Block and report to Claude
297
+ desc = policy.get("description", "Protected file")
298
+ print(f"veto-leash: BLOCKED {policy_action}", file=sys.stderr)
299
+ print(f" Target: {rel_target}", file=sys.stderr)
300
+ print(f" Policy: {desc}", file=sys.stderr)
301
+ print(f" Filesystem unchanged.", file=sys.stderr)
302
+ sys.exit(2)
303
+
304
+ sys.exit(0) # Allow
305
+
306
+ if __name__ == "__main__":
307
+ main()
308
+ `;
@@ -0,0 +1,131 @@
1
+ // src/native/cursor.ts
2
+ // Cursor integration via .cursorrules
3
+ // Cursor doesn't have a hook/permission system - only AI instruction rules
4
+ // We generate .cursorrules that instruct the AI to respect restrictions
5
+
6
+ import { existsSync, writeFileSync, readFileSync, appendFileSync } from 'fs';
7
+ import { join } from 'path';
8
+ import type { Policy } from '../types.js';
9
+ import { COLORS, SYMBOLS } from '../ui/colors.js';
10
+
11
+ const CURSORRULES_FILE = '.cursorrules';
12
+
13
+ /**
14
+ * Install veto-leash instructions into .cursorrules
15
+ * Note: This only provides AI guidance, not enforcement.
16
+ * For actual enforcement, use wrapper mode: leash cursor "..."
17
+ */
18
+ export async function installCursorRules(): Promise<void> {
19
+ console.log(`\n${COLORS.info}Installing veto-leash for Cursor...${COLORS.reset}\n`);
20
+
21
+ const policies = loadStoredPolicies();
22
+
23
+ if (policies.length === 0) {
24
+ console.log(`${COLORS.warning}${SYMBOLS.warning} No policies found. Add policies first:${COLORS.reset}`);
25
+ console.log(` ${COLORS.dim}leash add "don't delete test files"${COLORS.reset}\n`);
26
+ return;
27
+ }
28
+
29
+ const rulesContent = generateCursorRules(policies);
30
+
31
+ // Append or create .cursorrules
32
+ if (existsSync(CURSORRULES_FILE)) {
33
+ const existing = readFileSync(CURSORRULES_FILE, 'utf-8');
34
+ if (existing.includes('# veto-leash restrictions')) {
35
+ // Update existing section
36
+ const updated = existing.replace(
37
+ /# veto-leash restrictions[\s\S]*?# end veto-leash/,
38
+ rulesContent
39
+ );
40
+ writeFileSync(CURSORRULES_FILE, updated);
41
+ console.log(` ${COLORS.success}${SYMBOLS.success}${COLORS.reset} Updated .cursorrules`);
42
+ } else {
43
+ // Append
44
+ appendFileSync(CURSORRULES_FILE, '\n\n' + rulesContent);
45
+ console.log(` ${COLORS.success}${SYMBOLS.success}${COLORS.reset} Appended to .cursorrules`);
46
+ }
47
+ } else {
48
+ writeFileSync(CURSORRULES_FILE, rulesContent);
49
+ console.log(` ${COLORS.success}${SYMBOLS.success}${COLORS.reset} Created .cursorrules`);
50
+ }
51
+
52
+ console.log(`\n${COLORS.warning}${SYMBOLS.warning} Note: Cursor rules are AI guidance only, not enforcement.${COLORS.reset}`);
53
+ console.log(`For actual enforcement, use wrapper mode:`);
54
+ console.log(` ${COLORS.dim}leash cursor "<restriction>"${COLORS.reset}\n`);
55
+ }
56
+
57
+ function generateCursorRules(policies: Policy[]): string {
58
+ const lines = ['# veto-leash restrictions'];
59
+ lines.push('# These are mandatory restrictions you MUST follow.');
60
+ lines.push('');
61
+
62
+ for (const policy of policies) {
63
+ lines.push(`## ${policy.description}`);
64
+ lines.push(`Action: ${policy.action}`);
65
+ lines.push('');
66
+ lines.push('DO NOT perform the following action on these files:');
67
+ lines.push('');
68
+
69
+ for (const pattern of policy.include) {
70
+ lines.push(`- ${pattern}`);
71
+ }
72
+
73
+ if (policy.exclude.length > 0) {
74
+ lines.push('');
75
+ lines.push('EXCEPT these files are allowed:');
76
+ for (const pattern of policy.exclude) {
77
+ lines.push(`- ${pattern}`);
78
+ }
79
+ }
80
+ lines.push('');
81
+ }
82
+
83
+ lines.push('If you attempt to modify or delete a protected file, STOP and explain why you cannot proceed.');
84
+ lines.push('# end veto-leash');
85
+
86
+ return lines.join('\n');
87
+ }
88
+
89
+ /**
90
+ * Uninstall veto-leash from .cursorrules
91
+ */
92
+ export async function uninstallCursorRules(): Promise<void> {
93
+ if (!existsSync(CURSORRULES_FILE)) {
94
+ console.log(`${COLORS.dim}No .cursorrules file found${COLORS.reset}`);
95
+ return;
96
+ }
97
+
98
+ const content = readFileSync(CURSORRULES_FILE, 'utf-8');
99
+ const updated = content.replace(
100
+ /\n*# veto-leash restrictions[\s\S]*?# end veto-leash\n*/,
101
+ ''
102
+ );
103
+
104
+ if (updated.trim()) {
105
+ writeFileSync(CURSORRULES_FILE, updated);
106
+ console.log(`${COLORS.success}${SYMBOLS.success} Removed veto-leash from .cursorrules${COLORS.reset}`);
107
+ } else {
108
+ // File would be empty, delete it
109
+ require('fs').unlinkSync(CURSORRULES_FILE);
110
+ console.log(`${COLORS.success}${SYMBOLS.success} Removed .cursorrules${COLORS.reset}`);
111
+ }
112
+ }
113
+
114
+ function loadStoredPolicies(): Policy[] {
115
+ const policiesFile = join(
116
+ require('os').homedir(),
117
+ '.config',
118
+ 'veto-leash',
119
+ 'policies.json'
120
+ );
121
+
122
+ try {
123
+ if (existsSync(policiesFile)) {
124
+ const data = JSON.parse(readFileSync(policiesFile, 'utf-8'));
125
+ return data.policies?.map((p: { policy: Policy }) => p.policy) || [];
126
+ }
127
+ } catch {
128
+ // Ignore
129
+ }
130
+ return [];
131
+ }