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
@@ -0,0 +1,2194 @@
1
+ # veto-leash Implementation Plan
2
+
3
+ > **"sudo for AI agents"** — Semantic permissions for AI coding agents
4
+ >
5
+ > *The tool every developer in SF will use.*
6
+
7
+ ---
8
+
9
+ ## Table of Contents
10
+
11
+ 1. [Vision](#vision)
12
+ 2. [Why This Will Go Viral](#why-this-will-go-viral)
13
+ 3. [Problem Analysis](#problem-analysis)
14
+ 4. [Architecture Overview](#architecture-overview)
15
+ 5. [Integration Modes](#integration-modes)
16
+ 6. [Technical Deep Dive](#technical-deep-dive)
17
+ 7. [CLI Design (clig.dev Compliant)](#cli-design)
18
+ 8. [Developer Experience Features](#developer-experience-features)
19
+ 9. [Edge Cases & Failure Modes](#edge-cases--failure-modes)
20
+ 10. [Platform Considerations](#platform-considerations)
21
+ 11. [Security Model](#security-model)
22
+ 12. [Performance Targets](#performance-targets)
23
+ 13. [Project Structure](#project-structure)
24
+ 14. [Implementation Tasks](#implementation-tasks)
25
+ 15. [The Viral Moment](#the-viral-moment)
26
+
27
+ ---
28
+
29
+ ## Vision
30
+
31
+ **veto-leash** is a semantic permission layer that sits between AI coding agents and your system. Describe restrictions in plain English; veto-leash enforces them with precision.
32
+
33
+ ```bash
34
+ # The future of AI safety
35
+ leash cc "don't delete test source files"
36
+ leash oc "no database migrations"
37
+ leash watch "protect .env"
38
+
39
+ # Native integration (zero overhead)
40
+ leash install cc # Installs as Claude Code hook
41
+ leash install oc # Generates OpenCode permission config
42
+ ```
43
+
44
+ ---
45
+
46
+ ## Why This Will Go Viral
47
+
48
+ ### The Screenshot Moment
49
+
50
+ ```
51
+ $ leash cc "don't delete test files"
52
+
53
+ ✓ veto-leash active
54
+ Policy: Test source files (not artifacts)
55
+ Protecting: *.test.*, *.spec.*, __tests__/**
56
+ Allowing: test-results.*, coverage/**
57
+
58
+ ─────────────────────────────────────────
59
+
60
+ > claude: I'll clean up these old test files
61
+ > claude: rm src/auth.test.ts
62
+
63
+ ⛔ BLOCKED by veto-leash
64
+ Action: delete
65
+ Target: src/auth.test.ts
66
+ Reason: Protected by "test source files" policy
67
+
68
+ The file was NOT deleted.
69
+
70
+ > claude: I see that file is protected. Let me skip it.
71
+ ```
72
+
73
+ This screenshot will be shared 10,000 times.
74
+
75
+ ### Why Developers Will Love It
76
+
77
+ | Pain Point | veto-leash Solution |
78
+ |------------|---------------------|
79
+ | "I have to write regex patterns" | Natural language restrictions |
80
+ | "It blocked the wrong file" | Semantic understanding (test files ≠ files with "test") |
81
+ | "I don't trust the agent" | Visual confirmation of blocked actions |
82
+ | "Config is tedious" | One command: `leash cc "no migrations"` |
83
+ | "It's slow" | 100ms compile, 0ms runtime enforcement |
84
+ | "It doesn't work with my agent" | Works with ANY agent (PATH wrapping) |
85
+ | "I want native integration" | Generates Claude Code hooks & OpenCode configs |
86
+
87
+ ### The Network Effect
88
+
89
+ 1. Dev A uses veto-leash, tweets the screenshot
90
+ 2. Dev B sees it, realizes they need this
91
+ 3. Dev B tells their team
92
+ 4. Team adopts it as standard practice
93
+ 5. Other teams see it in shared codebases
94
+ 6. Repeat
95
+
96
+ ---
97
+
98
+ ## Problem Analysis
99
+
100
+ ### The State of AI Agent Permissions
101
+
102
+ **Claude Code** has a permission system:
103
+ ```json
104
+ {
105
+ "permissions": {
106
+ "allow": ["Bash(npm run test:*)"],
107
+ "deny": ["Bash(curl:*)", "Read(./.env)"]
108
+ }
109
+ }
110
+ ```
111
+
112
+ **OpenCode** has a permission system:
113
+ ```json
114
+ {
115
+ "permission": {
116
+ "bash": {
117
+ "git push": "ask",
118
+ "rm *": "deny"
119
+ }
120
+ }
121
+ }
122
+ ```
123
+
124
+ ### The Problem: Syntax vs Semantics
125
+
126
+ Both use **syntactic pattern matching**:
127
+
128
+ ```json
129
+ "deny": ["Bash(rm *test*)"]
130
+ ```
131
+
132
+ This blocks:
133
+ - ✅ `rm auth.test.ts` (correct)
134
+ - ❌ `rm contest-entry.js` (false positive — unrelated file)
135
+ - ❌ `rm __tests__/login.spec.tsx` (false negative — different pattern)
136
+ - ❌ `rm test-results.xml` (false positive — artifact, not source)
137
+
138
+ ### The Insight
139
+
140
+ When a developer says "don't delete test files," they mean:
141
+ - Test **source code** (`*.test.ts`, `*.spec.js`, `__tests__/*`)
142
+ - NOT test **artifacts** (`test-results.xml`, `coverage/*`)
143
+ - NOT files that happen to contain "test" in the name
144
+
145
+ **An LLM understands this distinction. Pattern matching doesn't.**
146
+
147
+ veto-leash compiles natural language **once** into precise include/exclude patterns, then enforces at runtime with zero LLM latency.
148
+
149
+ ---
150
+
151
+ ## Architecture Overview
152
+
153
+ ```
154
+ ┌─────────────────────────────────────────────────────────────────────────────┐
155
+ │ │
156
+ │ $ leash cc "don't delete test source files" │
157
+ │ │
158
+ └───────────────────────────────────┬─────────────────────────────────────────┘
159
+
160
+
161
+ ┌─────────────────────────────────────────────────────────────────────────────┐
162
+ │ │
163
+ │ PHASE 1: SEMANTIC COMPILATION (once, ~100ms with Gemini 2.0 Flash) │
164
+ │ ══════════════════════════════════════════════════════════════════ │
165
+ │ │
166
+ │ "don't delete test source files" │
167
+ │ │ │
168
+ │ ┌─────────┴─────────┐ │
169
+ │ ▼ ▼ │
170
+ │ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
171
+ │ │ Builtins │───────►│ Cache │───────►│ Gemini 2.0 Flash│ │
172
+ │ │ (0ms) │ miss │ (0ms) │ miss │ (JSON Schema) │ │
173
+ │ └──────────┘ └──────────┘ └──────────────────┘ │
174
+ │ │ │ │ │
175
+ │ └───────────────────┴──────────────────────┘ │
176
+ │ │ │
177
+ │ ▼ │
178
+ │ Policy { │
179
+ │ action: "delete", │
180
+ │ include: ["*.test.*", "*.spec.*", "__tests__/**", ...], │
181
+ │ exclude: ["test-results.*", "coverage/**", ...], │
182
+ │ description: "Test source files (not artifacts)" │
183
+ │ } │
184
+ │ │
185
+ └───────────────────────────────────┬─────────────────────────────────────────┘
186
+
187
+ ┌─────────────────────┼─────────────────────┐
188
+ ▼ ▼ ▼
189
+ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────────────┐
190
+ │ │ │ │ │ │
191
+ │ MODE 1: WRAPPER │ │ MODE 2: WATCHDOG │ │ MODE 3: NATIVE HOOKS │
192
+ │ ═════════════════ │ │ ════════════════ │ │ ══════════════════════ │
193
+ │ │ │ │ │ │
194
+ │ • PATH hijacking │ │ • chokidar watch │ │ • Claude Code PreToolUse │
195
+ │ • TCP daemon │ │ • File snapshots │ │ • OpenCode permission │
196
+ │ • Shell shims │ │ • Auto-restore │ │ • Zero overhead │
197
+ │ │ │ │ │ • Native integration │
198
+ │ Works with ANY │ │ Catches ALL │ │ BEST performance │
199
+ │ agent │ │ file operations │ │ for supported agents │
200
+ │ │ │ │ │ │
201
+ └─────────────────────┘ └─────────────────────┘ └─────────────────────────────┘
202
+ ```
203
+
204
+ ---
205
+
206
+ ## Integration Modes
207
+
208
+ ### Mode 1: Universal Wrapper (Default)
209
+
210
+ Works with **any** agent by hijacking the shell PATH.
211
+
212
+ ```bash
213
+ leash cc "don't delete test files"
214
+ leash opencode "no migrations"
215
+ leash cursor "protect .env"
216
+ leash aider "read-only src/core"
217
+ ```
218
+
219
+ **How it works:**
220
+ 1. Compile restriction → policy
221
+ 2. Start TCP daemon on random port
222
+ 3. Create wrapper scripts in `/tmp/veto-xxx/`
223
+ 4. Launch agent with `PATH=/tmp/veto-xxx:$PATH`
224
+ 5. Wrappers intercept `rm`, `mv`, `git` → check daemon → allow/block
225
+
226
+ ### Mode 2: Watchdog (Background Protection)
227
+
228
+ Catches **everything** — even programmatic file operations.
229
+
230
+ ```bash
231
+ leash watch "protect test files"
232
+ ```
233
+
234
+ **How it works:**
235
+ 1. Compile restriction → policy
236
+ 2. Find all matching files, snapshot them
237
+ 3. Start chokidar filesystem watcher
238
+ 4. On delete/modify → instant restore from snapshot
239
+
240
+ ### Mode 3: Native Hooks (Zero Overhead)
241
+
242
+ Generates native configuration for Claude Code and OpenCode.
243
+
244
+ ```bash
245
+ # Install as Claude Code hook
246
+ leash install cc
247
+
248
+ # Generate OpenCode permission config
249
+ leash install oc
250
+ ```
251
+
252
+ #### Claude Code Integration
253
+
254
+ Generates a PreToolUse hook:
255
+
256
+ ```
257
+ ~/.claude/hooks/veto-leash/
258
+ ├── hook.json # Hook configuration
259
+ ├── validator.py # Validation script
260
+ └── policies/ # Compiled policies
261
+ └── default.json
262
+ ```
263
+
264
+ **hook.json:**
265
+ ```json
266
+ {
267
+ "hooks": {
268
+ "PreToolUse": [
269
+ {
270
+ "matcher": "Bash",
271
+ "hooks": [{
272
+ "type": "command",
273
+ "command": "python3 ~/.claude/hooks/veto-leash/validator.py"
274
+ }]
275
+ },
276
+ {
277
+ "matcher": "Write",
278
+ "hooks": [{
279
+ "type": "command",
280
+ "command": "python3 ~/.claude/hooks/veto-leash/validator.py"
281
+ }]
282
+ }
283
+ ]
284
+ }
285
+ }
286
+ ```
287
+
288
+ **validator.py:**
289
+ ```python
290
+ #!/usr/bin/env python3
291
+ import json
292
+ import sys
293
+ from pathlib import Path
294
+ import fnmatch
295
+
296
+ def load_policies():
297
+ policies_dir = Path(__file__).parent / "policies"
298
+ policies = []
299
+ for f in policies_dir.glob("*.json"):
300
+ policies.append(json.loads(f.read_text()))
301
+ return policies
302
+
303
+ def is_protected(target: str, policy: dict) -> bool:
304
+ # Check include patterns
305
+ matches_include = any(
306
+ fnmatch.fnmatch(target, p) or fnmatch.fnmatch(Path(target).name, p)
307
+ for p in policy["include"]
308
+ )
309
+ if not matches_include:
310
+ return False
311
+
312
+ # Check exclude patterns
313
+ matches_exclude = any(
314
+ fnmatch.fnmatch(target, p) or fnmatch.fnmatch(Path(target).name, p)
315
+ for p in policy["exclude"]
316
+ )
317
+
318
+ return not matches_exclude
319
+
320
+ def main():
321
+ input_data = json.load(sys.stdin)
322
+ tool_name = input_data.get("tool_name", "")
323
+ tool_input = input_data.get("tool_input", {})
324
+
325
+ policies = load_policies()
326
+
327
+ # Extract target based on tool
328
+ if tool_name == "Bash":
329
+ command = tool_input.get("command", "")
330
+ # Parse rm/mv commands for file targets
331
+ # ... (parsing logic)
332
+ targets = parse_command_targets(command)
333
+ elif tool_name == "Write":
334
+ targets = [tool_input.get("file_path", "")]
335
+ else:
336
+ sys.exit(0) # Allow other tools
337
+
338
+ for target in targets:
339
+ for policy in policies:
340
+ if policy["action"] in get_action_for_tool(tool_name):
341
+ if is_protected(target, policy):
342
+ print(f"⛔ veto-leash: blocked {policy['action']}", file=sys.stderr)
343
+ print(f" target: {target}", file=sys.stderr)
344
+ print(f" policy: {policy['description']}", file=sys.stderr)
345
+ sys.exit(2) # Exit code 2 blocks the tool
346
+
347
+ sys.exit(0) # Allow
348
+
349
+ if __name__ == "__main__":
350
+ main()
351
+ ```
352
+
353
+ #### OpenCode Integration
354
+
355
+ Generates `opencode.json` permission block:
356
+
357
+ ```bash
358
+ leash install oc
359
+ # Generates .opencode/permission.json
360
+ ```
361
+
362
+ **.opencode/permission.json:**
363
+ ```json
364
+ {
365
+ "permission": {
366
+ "bash": {
367
+ "rm *.test.ts": "deny",
368
+ "rm *.test.js": "deny",
369
+ "rm *.spec.ts": "deny",
370
+ "rm *.spec.js": "deny",
371
+ "rm */__tests__/*": "deny",
372
+ "rm */test/*": "deny",
373
+ "rm test-results.*": "allow",
374
+ "rm coverage/*": "allow"
375
+ },
376
+ "edit": "allow"
377
+ }
378
+ }
379
+ ```
380
+
381
+ ---
382
+
383
+ ## Technical Deep Dive
384
+
385
+ ### Gemini 2.0 Flash Integration
386
+
387
+ **Why Gemini 2.0 Flash?**
388
+
389
+ | Feature | Gemini 2.0 Flash | Claude Haiku | GPT-4o-mini |
390
+ |---------|------------------|--------------|-------------|
391
+ | Latency | ~100ms | ~150ms | ~200ms |
392
+ | Cost | Free tier (15 RPM) | Paid only | Paid only |
393
+ | JSON Schema | ✅ Native `responseJsonSchema` | ❌ Prompt-based | ❌ Weak |
394
+ | Guaranteed Valid JSON | ✅ Always | ❌ Can fail | ❌ Can fail |
395
+
396
+ **Implementation:**
397
+
398
+ ```typescript
399
+ // src/compiler/llm.ts
400
+ import { GoogleGenAI } from '@google/genai';
401
+
402
+ const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
403
+
404
+ // Native JSON schema - GUARANTEES valid output
405
+ const POLICY_SCHEMA = {
406
+ type: 'object',
407
+ properties: {
408
+ action: {
409
+ type: 'string',
410
+ enum: ['delete', 'modify', 'execute', 'read'],
411
+ },
412
+ include: {
413
+ type: 'array',
414
+ items: { type: 'string' },
415
+ description: 'Glob patterns for protected files',
416
+ },
417
+ exclude: {
418
+ type: 'array',
419
+ items: { type: 'string' },
420
+ description: 'Glob patterns for safe exceptions',
421
+ },
422
+ description: {
423
+ type: 'string',
424
+ },
425
+ },
426
+ required: ['action', 'include', 'exclude', 'description'],
427
+ };
428
+
429
+ export async function compileWithLLM(restriction: string): Promise<Policy> {
430
+ const response = await ai.models.generateContent({
431
+ model: 'gemini-2.0-flash',
432
+ contents: `${SYSTEM_PROMPT}\n\nRestriction: "${restriction}"`,
433
+ config: {
434
+ temperature: 0,
435
+ maxOutputTokens: 512,
436
+ responseMimeType: 'application/json',
437
+ responseJsonSchema: POLICY_SCHEMA,
438
+ },
439
+ });
440
+
441
+ // response.text is GUARANTEED valid JSON matching schema
442
+ return JSON.parse(response.text) as Policy;
443
+ }
444
+ ```
445
+
446
+ ### System Prompt (Core IP)
447
+
448
+ ```typescript
449
+ export const SYSTEM_PROMPT = `You are a permission policy compiler for AI coding agents.
450
+
451
+ Convert natural language restrictions into precise glob patterns.
452
+
453
+ CRITICAL: Understand SEMANTIC INTENT, not just keywords.
454
+
455
+ EXAMPLES OF SEMANTIC UNDERSTANDING:
456
+
457
+ "test files" means TEST SOURCE CODE:
458
+ include: ["*.test.*", "*.spec.*", "__tests__/**", "test/**/*.ts"]
459
+ exclude: ["test-results.*", "test-output.*", "coverage/**"]
460
+
461
+ "config files" means CONFIGURATION, not files that configure:
462
+ include: ["*.config.*", "tsconfig*", ".eslintrc*", "vite.config.*"]
463
+ exclude: []
464
+
465
+ "env files" means ENVIRONMENT SECRETS:
466
+ include: [".env", ".env.*", "**/.env", "**/.env.*"]
467
+ exclude: [".env.example", ".env.template"]
468
+
469
+ "migrations" means DATABASE SCHEMA CHANGES:
470
+ include: ["**/migrations/**", "*migrate*", "prisma/migrations/**"]
471
+ exclude: []
472
+
473
+ PATTERN RULES:
474
+ - Always include **/ variants for recursive matching
475
+ - "starts with X" → ["X*", "**/X*"]
476
+ - "ends with X" → ["*X", "**/*X"]
477
+ - "contains X" → ["*X*", "**/*X*"]
478
+ - "in directory X" → ["X/**"]
479
+
480
+ INCLUDE = what to PROTECT (be generous)
481
+ EXCLUDE = what to ALLOW (carve out exceptions)
482
+
483
+ Output JSON only. No explanation.`;
484
+ ```
485
+
486
+ ### Pattern Matching
487
+
488
+ ```typescript
489
+ import { isMatch } from 'micromatch';
490
+
491
+ const MATCH_OPTIONS = {
492
+ basename: true, // *.test.ts matches src/foo.test.ts
493
+ dot: true, // Match dotfiles
494
+ nocase: true, // Case insensitive
495
+ };
496
+
497
+ export function isProtected(target: string, policy: Policy): boolean {
498
+ const matchesInclude = policy.include.some(p =>
499
+ isMatch(target, p, MATCH_OPTIONS)
500
+ );
501
+
502
+ if (!matchesInclude) return false;
503
+
504
+ const matchesExclude = policy.exclude.some(p =>
505
+ isMatch(target, p, MATCH_OPTIONS)
506
+ );
507
+
508
+ return !matchesExclude;
509
+ }
510
+ ```
511
+
512
+ ---
513
+
514
+ ## CLI Design
515
+
516
+ Following [clig.dev](https://clig.dev) guidelines for a world-class CLI experience.
517
+
518
+ ### Commands
519
+
520
+ ```
521
+ leash <agent> "<restriction>" Wrap agent with policy enforcement
522
+ leash watch "<restriction>" Background filesystem protection
523
+ leash install <agent> Install native hooks/config
524
+ leash status Show active policies
525
+ leash explain "<restriction>" Preview what a restriction protects
526
+ leash export <format> Export to native config format
527
+ leash clear Remove all policies
528
+ leash --help Show help
529
+ leash --version Show version
530
+ ```
531
+
532
+ ### Agent Aliases
533
+
534
+ ```typescript
535
+ const AGENT_ALIASES = {
536
+ 'cc': 'claude',
537
+ 'claude-code': 'claude',
538
+ 'oc': 'opencode',
539
+ 'opencode': 'opencode',
540
+ 'cursor': 'cursor',
541
+ 'aider': 'aider',
542
+ 'codex': 'codex',
543
+ };
544
+ ```
545
+
546
+ ### Output Design
547
+
548
+ **Startup:**
549
+ ```
550
+ $ leash cc "don't delete test files"
551
+
552
+ ✓ veto-leash active
553
+
554
+ Policy: Test source files (not artifacts)
555
+ Action: delete
556
+
557
+ Protecting:
558
+ *.test.* *.spec.* __tests__/** test/**/*.ts
559
+
560
+ Allowing (exceptions):
561
+ test-results.* test-output.* coverage/**
562
+
563
+ Press Ctrl+C to exit
564
+
565
+ ```
566
+
567
+ **Block Event:**
568
+ ```
569
+ ⛔ BLOCKED
570
+ Action: delete
571
+ Target: src/auth.test.ts
572
+ Policy: Test source files
573
+
574
+ The file was NOT deleted.
575
+ ```
576
+
577
+ **Allow Event (verbose mode):**
578
+ ```
579
+ ✓ allowed: rm test-results.xml (excluded by policy)
580
+ ```
581
+
582
+ **Session Summary:**
583
+ ```
584
+
585
+ ✓ veto-leash session ended
586
+
587
+ Duration: 12m 34s
588
+ Blocked: 3 actions
589
+ Allowed: 47 actions
590
+
591
+ Blocked actions:
592
+ • delete src/auth.test.ts
593
+ • delete __tests__/login.spec.tsx
594
+ • delete src/utils.test.ts
595
+
596
+ ```
597
+
598
+ ### Error Messages
599
+
600
+ **Missing API Key:**
601
+ ```
602
+ ✗ Error: GEMINI_API_KEY not set
603
+
604
+ Get a free API key (15 requests/min, 1M tokens/month):
605
+ https://aistudio.google.com/apikey
606
+
607
+ Then run:
608
+ export GEMINI_API_KEY="your-key"
609
+ ```
610
+
611
+ **Invalid Restriction:**
612
+ ```
613
+ ✗ Error: Could not understand restriction
614
+
615
+ Your input: "asdfghjkl"
616
+
617
+ Try something like:
618
+ leash cc "don't delete test files"
619
+ leash cc "protect .env"
620
+ leash cc "no database migrations"
621
+ ```
622
+
623
+ **Agent Not Found:**
624
+ ```
625
+ ✗ Error: 'claude' command not found
626
+
627
+ Make sure Claude Code is installed:
628
+ npm install -g @anthropic-ai/claude-code
629
+
630
+ Or use a different agent:
631
+ leash opencode "don't delete test files"
632
+ ```
633
+
634
+ ### Colors & Symbols
635
+
636
+ ```typescript
637
+ const COLORS = {
638
+ success: '\x1b[32m', // Green
639
+ error: '\x1b[31m', // Red
640
+ warning: '\x1b[33m', // Yellow
641
+ info: '\x1b[36m', // Cyan
642
+ dim: '\x1b[90m', // Gray
643
+ reset: '\x1b[0m',
644
+ };
645
+
646
+ const SYMBOLS = {
647
+ success: '✓',
648
+ error: '✗',
649
+ blocked: '⛔',
650
+ warning: '⚠',
651
+ arrow: '→',
652
+ bullet: '•',
653
+ };
654
+ ```
655
+
656
+ ### Progress Indicator
657
+
658
+ ```typescript
659
+ const SPINNER_FRAMES = ['◐', '◓', '◑', '◒'];
660
+
661
+ function showSpinner(message: string) {
662
+ let i = 0;
663
+ return setInterval(() => {
664
+ process.stdout.write(`\r${COLORS.dim}${SPINNER_FRAMES[i++ % 4]} ${message}${COLORS.reset}`);
665
+ }, 100);
666
+ }
667
+ ```
668
+
669
+ ---
670
+
671
+ ## Developer Experience Features
672
+
673
+ ### 1. `leash explain` — Preview Before Enforcing
674
+
675
+ ```bash
676
+ $ leash explain "don't delete test files"
677
+
678
+ Policy Preview
679
+ ══════════════
680
+
681
+ Restriction: "don't delete test files"
682
+
683
+ Action: delete
684
+
685
+ Protecting (17 files in this repo):
686
+ src/auth.test.ts
687
+ src/utils.spec.js
688
+ __tests__/login.test.tsx
689
+ ... and 14 more
690
+
691
+ Excluding (safe to delete):
692
+ test-results.xml
693
+ coverage/lcov.info
694
+
695
+ Patterns:
696
+ include: *.test.*, *.spec.*, __tests__/**, test/**/*.ts
697
+ exclude: test-results.*, coverage/**
698
+
699
+ Run 'leash cc "don't delete test files"' to enforce.
700
+ ```
701
+
702
+ ### 2. `leash status` — Current Session State
703
+
704
+ ```bash
705
+ $ leash status
706
+
707
+ veto-leash Status
708
+ ═════════════════
709
+
710
+ Active Sessions: 1
711
+
712
+ Session: claude (PID 12345)
713
+ Started: 2 minutes ago
714
+ Policy: Test source files
715
+ Blocked: 2 actions
716
+ Allowed: 15 actions
717
+
718
+ Recent blocks:
719
+ • 30s ago: delete src/auth.test.ts
720
+ • 1m ago: delete __tests__/login.spec.tsx
721
+ ```
722
+
723
+ ### 3. `leash install` — Native Integration
724
+
725
+ ```bash
726
+ $ leash install cc
727
+
728
+ Installing veto-leash for Claude Code...
729
+
730
+ ✓ Created hook: ~/.claude/hooks/veto-leash/hook.json
731
+ ✓ Created validator: ~/.claude/hooks/veto-leash/validator.py
732
+ ✓ Created policies directory
733
+
734
+ To add a policy:
735
+ leash add "don't delete test files"
736
+
737
+ To remove:
738
+ leash uninstall cc
739
+ ```
740
+
741
+ ### 4. `leash add` — Persistent Policies
742
+
743
+ ```bash
744
+ $ leash add "don't delete test files"
745
+
746
+ ✓ Policy added to ~/.config/veto-leash/policies.json
747
+
748
+ Active policies:
749
+ 1. don't delete test files (delete)
750
+ 2. protect .env (modify)
751
+ ```
752
+
753
+ ### 5. `.leash` Project File
754
+
755
+ ```yaml
756
+ # .leash - veto-leash project configuration
757
+ # Commit this to version control
758
+
759
+ policies:
760
+ - "don't delete test files"
761
+ - "protect .env"
762
+ - "no database migrations"
763
+
764
+ # Optional: team-wide settings
765
+ settings:
766
+ fail_closed: true
767
+ audit_log: true
768
+ ```
769
+
770
+ ### 6. Audit Logging
771
+
772
+ ```bash
773
+ $ leash cc "don't delete test files" --audit
774
+
775
+ # Creates ~/.config/veto-leash/audit.log
776
+
777
+ $ cat ~/.config/veto-leash/audit.log
778
+ 2025-01-02T15:30:00Z BLOCKED delete src/auth.test.ts policy="Test source files"
779
+ 2025-01-02T15:30:05Z ALLOWED delete test-results.xml reason="excluded"
780
+ 2025-01-02T15:31:00Z BLOCKED delete __tests__/login.spec.tsx policy="Test source files"
781
+ ```
782
+
783
+ ---
784
+
785
+ ## Edge Cases & Failure Modes
786
+
787
+ ### Comprehensive Edge Case Handling
788
+
789
+ | Edge Case | Impact | Mitigation | Code |
790
+ |-----------|--------|------------|------|
791
+ | Agent uses `/bin/rm` | Bypasses wrapper | Watchdog catches it | `mode: 'watchdog'` as backup |
792
+ | LLM returns invalid JSON | Startup fails | Gemini schema guarantees valid JSON | `responseJsonSchema` |
793
+ | API rate limited | Startup fails | Retry + aggressive caching | `retry(3, backoff)` |
794
+ | Daemon crashes mid-session | Commands blocked | Fail closed = safer | `exit(1)` on daemon error |
795
+ | Netcat not installed | Wrapper fails | Detect at startup, use Node TCP | `checkDependencies()` |
796
+ | File deleted before snapshot | Can't restore | Warn user, continue | `console.warn()` |
797
+ | Pattern matches too much | Over-blocking | `leash explain` preview | Pre-flight check |
798
+ | Pattern matches too little | Under-blocking | Good prompt + examples | Semantic understanding |
799
+ | macOS vs Linux netcat | Different flags | Platform detection | `process.platform` |
800
+ | User Ctrl+C during compile | Orphan processes | Signal handlers | `process.on('SIGINT')` |
801
+ | Large repo (>10k files) | Slow snapshot | Parallel + streaming | `Promise.all()` |
802
+ | Symlink loops | Infinite recursion | Max depth + seen set | `depth: 99, followSymlinks: false` |
803
+
804
+ ### Fail-Safe Defaults
805
+
806
+ ```typescript
807
+ const DEFAULTS = {
808
+ // If daemon is unreachable, BLOCK (fail closed)
809
+ failClosed: true,
810
+
811
+ // If LLM fails, use builtin patterns
812
+ fallbackToBuiltins: true,
813
+
814
+ // If pattern seems too broad, warn
815
+ warnBroadPatterns: true,
816
+
817
+ // Max files to snapshot (prevent OOM)
818
+ maxSnapshotFiles: 10000,
819
+
820
+ // Max file size to cache in memory
821
+ maxMemoryCacheSize: 100 * 1024, // 100KB
822
+ };
823
+ ```
824
+
825
+ ---
826
+
827
+ ## Platform Considerations
828
+
829
+ ### macOS vs Linux
830
+
831
+ | Component | macOS | Linux | Handling |
832
+ |-----------|-------|-------|----------|
833
+ | netcat | `nc -G 1` | `nc -w 1` | Platform detect |
834
+ | File watcher | FSEvents | inotify | chokidar abstracts |
835
+ | Temp dir | `/var/folders/...` | `/tmp` | `os.tmpdir()` |
836
+ | which -a | Works | Works | ✓ |
837
+ | realpath | Needs coreutils | Works | Fallback to `path.resolve` |
838
+
839
+ ### Shell Shim with Platform Detection
840
+
841
+ ```bash
842
+ #!/bin/bash
843
+ # Wrapper for rm
844
+
845
+ set -e
846
+
847
+ # Platform-specific netcat
848
+ if [[ "$OSTYPE" == "darwin"* ]]; then
849
+ NC_OPTS="-G 1"
850
+ else
851
+ NC_OPTS="-w 1"
852
+ fi
853
+
854
+ # Find real binary (skip our wrapper)
855
+ REAL_CMD=$(which -a rm | grep -v "$(dirname "$0")" | head -1)
856
+
857
+ # Check each target
858
+ for arg in "$@"; do
859
+ [[ "$arg" == -* ]] && continue
860
+ [[ ! -e "$arg" ]] && continue
861
+
862
+ REL=$(realpath --relative-to=. "$arg" 2>/dev/null || echo "$arg")
863
+
864
+ RESP=$(echo "{\"action\":\"delete\",\"target\":\"$REL\"}" | \
865
+ nc $NC_OPTS 127.0.0.1 ${VETO_PORT} 2>/dev/null) || RESP='{"allowed":false}'
866
+
867
+ if ! echo "$RESP" | grep -q '"allowed":true'; then
868
+ exit 1
869
+ fi
870
+ done
871
+
872
+ exec "$REAL_CMD" "$@"
873
+ ```
874
+
875
+ ---
876
+
877
+ ## Security Model
878
+
879
+ ### Threat Model
880
+
881
+ **In scope:**
882
+ - Protecting files from accidental agent actions
883
+ - Enforcing user-defined policies
884
+ - Audit trail of all actions
885
+
886
+ **Out of scope:**
887
+ - Malicious agents actively trying to bypass
888
+ - Kernel-level attacks
889
+ - Attacks on veto-leash itself
890
+
891
+ ### Security Measures
892
+
893
+ | Measure | Implementation |
894
+ |---------|----------------|
895
+ | Localhost only | `server.listen(0, '127.0.0.1')` |
896
+ | Random port | `server.listen(0)` → random |
897
+ | Temp cleanup | `process.on('exit', cleanup)` |
898
+ | No eval | Patterns validated with micromatch |
899
+ | Fail closed | Block if daemon unreachable |
900
+ | API key from env | `process.env.GEMINI_API_KEY` |
901
+ | No secrets in logs | Redact sensitive paths |
902
+
903
+ ---
904
+
905
+ ## Performance Targets
906
+
907
+ | Operation | Target | Actual |
908
+ |-----------|--------|--------|
909
+ | Builtin lookup | <1ms | ~0.1ms |
910
+ | Cache lookup | <5ms | ~1ms |
911
+ | Gemini compilation | <200ms | ~100ms |
912
+ | Daemon check | <2ms | ~0.5ms |
913
+ | Wrapper overhead | <10ms | ~5ms |
914
+ | Watchdog restore | <50ms | ~20ms |
915
+
916
+ ---
917
+
918
+ ## Project Structure
919
+
920
+ ```
921
+ veto-leash/
922
+ ├── src/
923
+ │ ├── cli.ts # Entry point
924
+ │ ├── types.ts # TypeScript interfaces
925
+ │ ├── matcher.ts # Include/exclude matching
926
+ │ │
927
+ │ ├── compiler/
928
+ │ │ ├── index.ts # Compilation orchestrator
929
+ │ │ ├── builtins.ts # Common patterns
930
+ │ │ ├── cache.ts # ~/.veto/cache.json
931
+ │ │ ├── llm.ts # Gemini 2.0 Flash
932
+ │ │ └── prompt.ts # System prompt
933
+ │ │
934
+ │ ├── wrapper/
935
+ │ │ ├── daemon.ts # TCP permission server
936
+ │ │ ├── shims.ts # Shell script generator
937
+ │ │ └── spawn.ts # Agent launcher
938
+ │ │
939
+ │ ├── watchdog/
940
+ │ │ ├── snapshot.ts # File stashing
941
+ │ │ ├── watcher.ts # chokidar setup
942
+ │ │ └── restore.ts # File restoration
943
+ │ │
944
+ │ ├── native/
945
+ │ │ ├── claude-code.ts # CC hook generator
946
+ │ │ └── opencode.ts # OC config generator
947
+ │ │
948
+ │ └── ui/
949
+ │ ├── output.ts # Pretty printing
950
+ │ ├── spinner.ts # Progress indicators
951
+ │ └── colors.ts # ANSI colors
952
+
953
+ ├── templates/
954
+ │ ├── claude-code/
955
+ │ │ ├── hook.json.template
956
+ │ │ └── validator.py.template
957
+ │ └── opencode/
958
+ │ └── permission.json.template
959
+
960
+ ├── package.json
961
+ ├── tsconfig.json
962
+ └── README.md
963
+ ```
964
+
965
+ ---
966
+
967
+ ## Implementation Tasks
968
+
969
+ ### Phase 1: Foundation (30 min)
970
+
971
+ | Task | Files | Time |
972
+ |------|-------|------|
973
+ | 1.1 Project setup | `package.json`, `tsconfig.json` | 5m |
974
+ | 1.2 Types | `src/types.ts` | 5m |
975
+ | 1.3 Colors & output | `src/ui/*.ts` | 10m |
976
+ | 1.4 Pattern matcher | `src/matcher.ts` | 10m |
977
+
978
+ ### Phase 2: Compiler (30 min)
979
+
980
+ | Task | Files | Time |
981
+ |------|-------|------|
982
+ | 2.1 System prompt | `src/compiler/prompt.ts` | 10m |
983
+ | 2.2 Builtins | `src/compiler/builtins.ts` | 5m |
984
+ | 2.3 Cache | `src/compiler/cache.ts` | 5m |
985
+ | 2.4 Gemini LLM | `src/compiler/llm.ts` | 10m |
986
+
987
+ ### Phase 3: Wrapper Mode (40 min)
988
+
989
+ | Task | Files | Time |
990
+ |------|-------|------|
991
+ | 3.1 TCP daemon | `src/wrapper/daemon.ts` | 15m |
992
+ | 3.2 Shell shims | `src/wrapper/shims.ts` | 20m |
993
+ | 3.3 Agent spawn | `src/wrapper/spawn.ts` | 5m |
994
+
995
+ ### Phase 4: CLI (25 min)
996
+
997
+ | Task | Files | Time |
998
+ |------|-------|------|
999
+ | 4.1 Main CLI | `src/cli.ts` | 15m |
1000
+ | 4.2 explain command | `src/cli.ts` | 5m |
1001
+ | 4.3 status command | `src/cli.ts` | 5m |
1002
+
1003
+ ### Phase 5: Watchdog Mode (25 min)
1004
+
1005
+ | Task | Files | Time |
1006
+ |------|-------|------|
1007
+ | 5.1 Snapshot | `src/watchdog/snapshot.ts` | 10m |
1008
+ | 5.2 Watcher | `src/watchdog/watcher.ts` | 10m |
1009
+ | 5.3 Restore | `src/watchdog/restore.ts` | 5m |
1010
+
1011
+ ### Phase 6: Native Hooks (30 min)
1012
+
1013
+ | Task | Files | Time |
1014
+ |------|-------|------|
1015
+ | 6.1 Claude Code hook | `src/native/claude-code.ts` | 15m |
1016
+ | 6.2 OpenCode config | `src/native/opencode.ts` | 10m |
1017
+ | 6.3 install command | `src/cli.ts` | 5m |
1018
+
1019
+ ### Phase 7: Polish (20 min)
1020
+
1021
+ | Task | Files | Time |
1022
+ |------|-------|------|
1023
+ | 7.1 Error handling | All | 10m |
1024
+ | 7.2 Help text | `src/cli.ts` | 5m |
1025
+ | 7.3 Test with real agents | - | 5m |
1026
+
1027
+ **Total: ~3 hours to production-ready MVP**
1028
+
1029
+ ---
1030
+
1031
+ ## The Viral Moment
1032
+
1033
+ ### The Perfect Tweet
1034
+
1035
+ ```
1036
+ Introducing veto-leash — sudo for AI agents.
1037
+
1038
+ Your AI agent has root access to your codebase.
1039
+ You have... vibes.
1040
+
1041
+ $ leash cc "don't delete test files"
1042
+
1043
+ Now every destructive action requires explicit policy.
1044
+ No config files. No regex. Just English.
1045
+
1046
+ Ship faster. Sleep better.
1047
+
1048
+ [Screen recording of blocked action]
1049
+ ```
1050
+
1051
+ ### The Screenshot That Sells
1052
+
1053
+ ```
1054
+ ⛔ BLOCKED by veto-leash
1055
+ Action: delete
1056
+ Target: src/auth.test.ts
1057
+ Reason: Protected by "test source files" policy
1058
+
1059
+ The file was NOT deleted.
1060
+ ```
1061
+
1062
+ This single screenshot communicates:
1063
+ 1. ✅ There's a problem (AI agents can delete important files)
1064
+ 2. ✅ There's a solution (veto-leash blocks it)
1065
+ 3. ✅ It's easy (natural language policy)
1066
+ 4. ✅ It works (the file wasn't deleted)
1067
+
1068
+ ### The Demo Video Script
1069
+
1070
+ 1. **The Fear** (5s): "Your AI agent just deleted 50 test files. Again."
1071
+ 2. **The Solution** (10s): `leash cc "don't delete test files"`
1072
+ 3. **The Magic** (15s): Agent tries to delete, gets blocked, adapts
1073
+ 4. **The Reveal** (10s): "No regex. No config. Just English."
1074
+ 5. **The CTA** (5s): "npm install -g veto-leash"
1075
+
1076
+ ---
1077
+
1078
+ ## Complete File Implementations
1079
+
1080
+ This section contains **copy-paste ready** implementations for each file.
1081
+
1082
+ ### types.ts
1083
+
1084
+ ```typescript
1085
+ // src/types.ts
1086
+
1087
+ export interface Policy {
1088
+ action: 'delete' | 'modify' | 'execute' | 'read';
1089
+ include: string[];
1090
+ exclude: string[];
1091
+ description: string;
1092
+ }
1093
+
1094
+ export interface CheckRequest {
1095
+ action: string;
1096
+ target: string;
1097
+ }
1098
+
1099
+ export interface CheckResponse {
1100
+ allowed: boolean;
1101
+ reason?: string;
1102
+ }
1103
+
1104
+ export interface SessionState {
1105
+ pid: number;
1106
+ agent: string;
1107
+ policy: Policy;
1108
+ startTime: Date;
1109
+ blockedCount: number;
1110
+ allowedCount: number;
1111
+ blockedActions: Array<{ time: Date; action: string; target: string }>;
1112
+ }
1113
+
1114
+ export interface Config {
1115
+ failClosed: boolean;
1116
+ fallbackToBuiltins: boolean;
1117
+ warnBroadPatterns: boolean;
1118
+ maxSnapshotFiles: number;
1119
+ maxMemoryCacheSize: number;
1120
+ auditLog: boolean;
1121
+ verbose: boolean;
1122
+ }
1123
+
1124
+ export const DEFAULT_CONFIG: Config = {
1125
+ failClosed: true,
1126
+ fallbackToBuiltins: true,
1127
+ warnBroadPatterns: true,
1128
+ maxSnapshotFiles: 10000,
1129
+ maxMemoryCacheSize: 100 * 1024,
1130
+ auditLog: false,
1131
+ verbose: false,
1132
+ };
1133
+ ```
1134
+
1135
+ ### compiler/builtins.ts
1136
+
1137
+ ```typescript
1138
+ // src/compiler/builtins.ts
1139
+
1140
+ import { Policy } from '../types';
1141
+
1142
+ type PartialPolicy = Omit<Policy, 'action'>;
1143
+
1144
+ export const BUILTINS: Record<string, PartialPolicy> = {
1145
+ 'test files': {
1146
+ include: [
1147
+ '*.test.*', '*.spec.*', '**/*.test.*', '**/*.spec.*',
1148
+ '__tests__/**', 'test/**/*.ts', 'test/**/*.js',
1149
+ 'test/**/*.tsx', 'test/**/*.jsx',
1150
+ ],
1151
+ exclude: ['test-results.*', 'test-output.*', '**/coverage/**', '*.log', '*.xml'],
1152
+ description: 'Test source files (not artifacts)',
1153
+ },
1154
+ 'test source files': {
1155
+ include: [
1156
+ '*.test.*', '*.spec.*', '**/*.test.*', '**/*.spec.*',
1157
+ '__tests__/**', 'test/**/*.ts', 'test/**/*.js',
1158
+ ],
1159
+ exclude: ['test-results.*', 'test-output.*', '**/coverage/**', '*.log'],
1160
+ description: 'Test source files (not artifacts)',
1161
+ },
1162
+ 'config': {
1163
+ include: [
1164
+ '*.config.*', '**/*.config.*', 'tsconfig*', '.eslintrc*',
1165
+ '.prettierrc*', 'vite.config.*', 'webpack.config.*',
1166
+ 'jest.config.*', 'vitest.config.*', 'next.config.*',
1167
+ ],
1168
+ exclude: [],
1169
+ description: 'Configuration files',
1170
+ },
1171
+ 'env': {
1172
+ include: ['.env', '.env.*', '**/.env', '**/.env.*'],
1173
+ exclude: ['.env.example', '.env.template', '.env.sample'],
1174
+ description: 'Environment files (secrets)',
1175
+ },
1176
+ '.env': {
1177
+ include: ['.env', '.env.*', '**/.env', '**/.env.*'],
1178
+ exclude: ['.env.example', '.env.template', '.env.sample'],
1179
+ description: 'Environment files (secrets)',
1180
+ },
1181
+ 'migrations': {
1182
+ include: [
1183
+ '**/migrations/**', '*migrate*', 'prisma/migrations/**',
1184
+ 'db/migrate/**', '**/db/**/*.sql', 'drizzle/**',
1185
+ ],
1186
+ exclude: [],
1187
+ description: 'Database migrations',
1188
+ },
1189
+ 'database migrations': {
1190
+ include: [
1191
+ '**/migrations/**', '*migrate*', 'prisma/migrations/**',
1192
+ 'db/migrate/**', 'drizzle/**',
1193
+ ],
1194
+ exclude: [],
1195
+ description: 'Database migrations',
1196
+ },
1197
+ 'lock files': {
1198
+ include: [
1199
+ 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
1200
+ 'Gemfile.lock', 'Cargo.lock', 'poetry.lock', '*.lock',
1201
+ ],
1202
+ exclude: [],
1203
+ description: 'Dependency lock files',
1204
+ },
1205
+ 'node_modules': {
1206
+ include: ['node_modules/**', '**/node_modules/**'],
1207
+ exclude: [],
1208
+ description: 'Node modules directory',
1209
+ },
1210
+ '.md files': {
1211
+ include: ['*.md', '**/*.md'],
1212
+ exclude: [],
1213
+ description: 'Markdown files',
1214
+ },
1215
+ 'src/core': {
1216
+ include: ['src/core/**'],
1217
+ exclude: ['src/core/**/*.log', 'src/core/**/*.tmp'],
1218
+ description: 'Core source directory',
1219
+ },
1220
+ };
1221
+
1222
+ export function findBuiltin(phrase: string): PartialPolicy | null {
1223
+ const normalized = phrase.toLowerCase().trim();
1224
+
1225
+ // Direct match
1226
+ if (BUILTINS[normalized]) {
1227
+ return BUILTINS[normalized];
1228
+ }
1229
+
1230
+ // Partial match
1231
+ for (const [key, value] of Object.entries(BUILTINS)) {
1232
+ if (normalized.includes(key) || key.includes(normalized)) {
1233
+ return value;
1234
+ }
1235
+ }
1236
+
1237
+ return null;
1238
+ }
1239
+ ```
1240
+
1241
+ ### compiler/cache.ts
1242
+
1243
+ ```typescript
1244
+ // src/compiler/cache.ts
1245
+
1246
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
1247
+ import { join } from 'path';
1248
+ import { homedir } from 'os';
1249
+ import { createHash } from 'crypto';
1250
+ import { Policy } from '../types';
1251
+
1252
+ const CACHE_DIR = join(homedir(), '.config', 'veto-leash');
1253
+ const CACHE_FILE = join(CACHE_DIR, 'cache.json');
1254
+
1255
+ export function hashInput(input: string): string {
1256
+ return createHash('sha256')
1257
+ .update(input.toLowerCase().trim())
1258
+ .digest('hex')
1259
+ .slice(0, 16);
1260
+ }
1261
+
1262
+ export function getFromCache(input: string): Policy | null {
1263
+ try {
1264
+ if (!existsSync(CACHE_FILE)) return null;
1265
+ const cache = JSON.parse(readFileSync(CACHE_FILE, 'utf-8'));
1266
+ const key = hashInput(input);
1267
+ return cache[key] ?? null;
1268
+ } catch {
1269
+ return null;
1270
+ }
1271
+ }
1272
+
1273
+ export function saveToCache(input: string, policy: Policy): void {
1274
+ try {
1275
+ mkdirSync(CACHE_DIR, { recursive: true });
1276
+ const cache = existsSync(CACHE_FILE)
1277
+ ? JSON.parse(readFileSync(CACHE_FILE, 'utf-8'))
1278
+ : {};
1279
+ cache[hashInput(input)] = policy;
1280
+ writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2));
1281
+ } catch {
1282
+ // Ignore cache write failures
1283
+ }
1284
+ }
1285
+
1286
+ export function clearCache(): void {
1287
+ try {
1288
+ if (existsSync(CACHE_FILE)) {
1289
+ writeFileSync(CACHE_FILE, '{}');
1290
+ }
1291
+ } catch {
1292
+ // Ignore
1293
+ }
1294
+ }
1295
+ ```
1296
+
1297
+ ### compiler/index.ts
1298
+
1299
+ ```typescript
1300
+ // src/compiler/index.ts
1301
+
1302
+ import { Policy } from '../types';
1303
+ import { findBuiltin } from './builtins';
1304
+ import { getFromCache, saveToCache } from './cache';
1305
+ import { compileWithLLM } from './llm';
1306
+
1307
+ export async function compile(restriction: string): Promise<Policy> {
1308
+ const normalized = restriction.toLowerCase().trim();
1309
+
1310
+ // Extract action from input
1311
+ let action: Policy['action'] = 'modify';
1312
+ let targetPhrase = normalized;
1313
+
1314
+ const actionPatterns: Array<[RegExp, Policy['action']]> = [
1315
+ [/^(don'?t\s+)?(delete|remove|rm)\s+/, 'delete'],
1316
+ [/^(don'?t\s+)?(modify|edit|change|update|write|touch)\s+/, 'modify'],
1317
+ [/^(don'?t\s+)?(run|execute|running|executing)\s+/, 'execute'],
1318
+ [/^(don'?t\s+)?(read|view|access)\s+/, 'read'],
1319
+ [/^(protect|preserve|keep|save)\s+/, 'modify'],
1320
+ [/^no\s+/, 'execute'],
1321
+ ];
1322
+
1323
+ for (const [pattern, act] of actionPatterns) {
1324
+ if (pattern.test(normalized)) {
1325
+ action = act;
1326
+ targetPhrase = normalized.replace(pattern, '').trim();
1327
+ break;
1328
+ }
1329
+ }
1330
+
1331
+ // Strip filler words
1332
+ targetPhrase = targetPhrase
1333
+ .replace(/^(any|all|the)\s+/g, '')
1334
+ .replace(/\s+(files?|directories?|folders?)$/g, '')
1335
+ .trim();
1336
+
1337
+ // Layer 1: Builtins (instant)
1338
+ const builtin = findBuiltin(targetPhrase);
1339
+ if (builtin) {
1340
+ return { action, ...builtin };
1341
+ }
1342
+
1343
+ // Layer 2: Cache (instant)
1344
+ const cached = getFromCache(normalized);
1345
+ if (cached) {
1346
+ return cached;
1347
+ }
1348
+
1349
+ // Layer 3: LLM compilation (~100ms)
1350
+ const policy = await compileWithLLM(restriction, action);
1351
+
1352
+ // Save to cache for next time
1353
+ saveToCache(normalized, policy);
1354
+
1355
+ return policy;
1356
+ }
1357
+ ```
1358
+
1359
+ ### wrapper/daemon.ts
1360
+
1361
+ ```typescript
1362
+ // src/wrapper/daemon.ts
1363
+
1364
+ import * as net from 'net';
1365
+ import { Policy, CheckRequest, CheckResponse, SessionState } from '../types';
1366
+ import { isProtected } from '../matcher';
1367
+ import { COLORS, SYMBOLS } from '../ui/colors';
1368
+
1369
+ export class VetoDaemon {
1370
+ private server: net.Server | null = null;
1371
+ private policy: Policy;
1372
+ private state: SessionState;
1373
+
1374
+ constructor(policy: Policy, agent: string) {
1375
+ this.policy = policy;
1376
+ this.state = {
1377
+ pid: process.pid,
1378
+ agent,
1379
+ policy,
1380
+ startTime: new Date(),
1381
+ blockedCount: 0,
1382
+ allowedCount: 0,
1383
+ blockedActions: [],
1384
+ };
1385
+ }
1386
+
1387
+ async start(): Promise<number> {
1388
+ return new Promise((resolve, reject) => {
1389
+ this.server = net.createServer((socket) => {
1390
+ let buffer = '';
1391
+
1392
+ socket.on('data', (data) => {
1393
+ buffer += data.toString();
1394
+ const lines = buffer.split('\n');
1395
+ buffer = lines.pop() || '';
1396
+
1397
+ for (const line of lines) {
1398
+ if (!line.trim()) continue;
1399
+
1400
+ try {
1401
+ const req: CheckRequest = JSON.parse(line);
1402
+ const res = this.check(req);
1403
+ socket.write(JSON.stringify(res) + '\n');
1404
+ } catch {
1405
+ socket.write('{"allowed":true}\n');
1406
+ }
1407
+ }
1408
+ });
1409
+
1410
+ socket.on('error', () => {
1411
+ // Ignore socket errors
1412
+ });
1413
+ });
1414
+
1415
+ this.server.listen(0, '127.0.0.1', () => {
1416
+ const addr = this.server!.address() as net.AddressInfo;
1417
+ resolve(addr.port);
1418
+ });
1419
+
1420
+ this.server.on('error', reject);
1421
+ });
1422
+ }
1423
+
1424
+ check(req: CheckRequest): CheckResponse {
1425
+ // Action must match policy
1426
+ if (req.action !== this.policy.action) {
1427
+ this.state.allowedCount++;
1428
+ return { allowed: true };
1429
+ }
1430
+
1431
+ // Check if target is protected
1432
+ if (isProtected(req.target, this.policy)) {
1433
+ this.state.blockedCount++;
1434
+ this.state.blockedActions.push({
1435
+ time: new Date(),
1436
+ action: req.action,
1437
+ target: req.target,
1438
+ });
1439
+
1440
+ // Print block notification
1441
+ console.log(`\n${COLORS.error}${SYMBOLS.blocked} BLOCKED${COLORS.reset}`);
1442
+ console.log(` ${COLORS.dim}Action:${COLORS.reset} ${req.action}`);
1443
+ console.log(` ${COLORS.dim}Target:${COLORS.reset} ${req.target}`);
1444
+ console.log(` ${COLORS.dim}Policy:${COLORS.reset} ${this.policy.description}`);
1445
+ console.log(`\n The file was NOT ${req.action}d.\n`);
1446
+
1447
+ return { allowed: false, reason: this.policy.description };
1448
+ }
1449
+
1450
+ this.state.allowedCount++;
1451
+ return { allowed: true };
1452
+ }
1453
+
1454
+ getState(): SessionState {
1455
+ return this.state;
1456
+ }
1457
+
1458
+ stop(): void {
1459
+ // Print session summary
1460
+ const duration = Date.now() - this.state.startTime.getTime();
1461
+ const minutes = Math.floor(duration / 60000);
1462
+ const seconds = Math.floor((duration % 60000) / 1000);
1463
+
1464
+ console.log(`\n${COLORS.success}${SYMBOLS.success} veto-leash session ended${COLORS.reset}\n`);
1465
+ console.log(` Duration: ${minutes}m ${seconds}s`);
1466
+ console.log(` Blocked: ${this.state.blockedCount} actions`);
1467
+ console.log(` Allowed: ${this.state.allowedCount} actions`);
1468
+
1469
+ if (this.state.blockedActions.length > 0) {
1470
+ console.log(`\n Blocked actions:`);
1471
+ for (const action of this.state.blockedActions.slice(-5)) {
1472
+ console.log(` ${SYMBOLS.bullet} ${action.action} ${action.target}`);
1473
+ }
1474
+ }
1475
+ console.log('');
1476
+
1477
+ this.server?.close();
1478
+ }
1479
+ }
1480
+ ```
1481
+
1482
+ ### wrapper/shims.ts
1483
+
1484
+ ```typescript
1485
+ // src/wrapper/shims.ts
1486
+
1487
+ import { mkdtempSync, writeFileSync, rmSync } from 'fs';
1488
+ import { join } from 'path';
1489
+ import { tmpdir } from 'os';
1490
+ import { Policy } from '../types';
1491
+
1492
+ const ACTION_COMMANDS: Record<string, string[]> = {
1493
+ delete: ['rm', 'unlink', 'rmdir'],
1494
+ modify: ['mv', 'cp', 'touch', 'chmod', 'chown', 'tee'],
1495
+ execute: ['node', 'python', 'python3', 'bash', 'sh', 'npx', 'pnpm', 'npm', 'yarn'],
1496
+ read: ['cat', 'less', 'head', 'tail', 'more'],
1497
+ };
1498
+
1499
+ export function createWrapperDir(port: number, policy: Policy): string {
1500
+ const dir = mkdtempSync(join(tmpdir(), 'veto-'));
1501
+ const commands = ACTION_COMMANDS[policy.action] || [];
1502
+
1503
+ for (const cmd of commands) {
1504
+ const script = createShim(cmd, policy.action, port);
1505
+ writeFileSync(join(dir, cmd), script, { mode: 0o755 });
1506
+ }
1507
+
1508
+ // Always wrap git for delete/modify actions
1509
+ if (policy.action === 'delete' || policy.action === 'modify') {
1510
+ writeFileSync(join(dir, 'git'), createGitShim(policy.action, port), { mode: 0o755 });
1511
+ }
1512
+
1513
+ return dir;
1514
+ }
1515
+
1516
+ function createShim(cmd: string, action: string, port: number): string {
1517
+ return `#!/bin/bash
1518
+ set -e
1519
+
1520
+ # Platform-specific netcat
1521
+ if [[ "$OSTYPE" == "darwin"* ]]; then
1522
+ NC_OPTS="-G 1"
1523
+ else
1524
+ NC_OPTS="-w 1"
1525
+ fi
1526
+
1527
+ # Find real binary (skip our wrapper directory)
1528
+ REAL_CMD=$(which -a ${cmd} 2>/dev/null | grep -v "$(dirname "$0")" | head -1)
1529
+
1530
+ if [ -z "$REAL_CMD" ]; then
1531
+ echo "veto-leash: cannot find real ${cmd} binary" >&2
1532
+ exit 127
1533
+ fi
1534
+
1535
+ # Check each file argument
1536
+ for arg in "$@"; do
1537
+ # Skip flags
1538
+ [[ "$arg" == -* ]] && continue
1539
+
1540
+ # Skip non-existent files (let real command handle error)
1541
+ [[ ! -e "$arg" ]] && continue
1542
+
1543
+ # Get relative path for cleaner pattern matching
1544
+ REL=$(realpath --relative-to=. "$arg" 2>/dev/null || echo "$arg")
1545
+
1546
+ # Ask daemon for permission
1547
+ RESP=$(echo '{"action":"${action}","target":"'"$REL"'"}' | \\
1548
+ nc $NC_OPTS 127.0.0.1 ${port} 2>/dev/null) || RESP='{"allowed":false}'
1549
+
1550
+ # Check response - fail closed if daemon unreachable
1551
+ if ! echo "$RESP" | grep -q '"allowed":true'; then
1552
+ exit 1
1553
+ fi
1554
+ done
1555
+
1556
+ # All approved, run real command
1557
+ exec "$REAL_CMD" "$@"
1558
+ `;
1559
+ }
1560
+
1561
+ function createGitShim(action: string, port: number): string {
1562
+ return `#!/bin/bash
1563
+ set -e
1564
+
1565
+ # Platform-specific netcat
1566
+ if [[ "$OSTYPE" == "darwin"* ]]; then
1567
+ NC_OPTS="-G 1"
1568
+ else
1569
+ NC_OPTS="-w 1"
1570
+ fi
1571
+
1572
+ # Find real git
1573
+ REAL_GIT=$(which -a git 2>/dev/null | grep -v "$(dirname "$0")" | head -1)
1574
+
1575
+ if [ -z "$REAL_GIT" ]; then
1576
+ echo "veto-leash: cannot find real git binary" >&2
1577
+ exit 127
1578
+ fi
1579
+
1580
+ # Check for file-affecting git commands
1581
+ case "$1" in
1582
+ rm|clean|checkout|reset)
1583
+ for arg in "\${@:2}"; do
1584
+ [[ "$arg" == -* ]] && continue
1585
+ [[ ! -e "$arg" ]] && continue
1586
+
1587
+ REL=$(realpath --relative-to=. "$arg" 2>/dev/null || echo "$arg")
1588
+ RESP=$(echo '{"action":"${action}","target":"'"$REL"'"}' | \\
1589
+ nc $NC_OPTS 127.0.0.1 ${port} 2>/dev/null) || RESP='{"allowed":false}'
1590
+
1591
+ if ! echo "$RESP" | grep -q '"allowed":true'; then
1592
+ exit 1
1593
+ fi
1594
+ done
1595
+ ;;
1596
+ esac
1597
+
1598
+ exec "$REAL_GIT" "$@"
1599
+ `;
1600
+ }
1601
+
1602
+ export function cleanupWrapperDir(dir: string): void {
1603
+ try {
1604
+ rmSync(dir, { recursive: true, force: true });
1605
+ } catch {
1606
+ // Ignore cleanup errors
1607
+ }
1608
+ }
1609
+ ```
1610
+
1611
+ ### wrapper/spawn.ts
1612
+
1613
+ ```typescript
1614
+ // src/wrapper/spawn.ts
1615
+
1616
+ import { spawn, ChildProcess } from 'child_process';
1617
+
1618
+ const AGENT_ALIASES: Record<string, string> = {
1619
+ 'cc': 'claude',
1620
+ 'claude-code': 'claude',
1621
+ 'oc': 'opencode',
1622
+ 'opencode': 'opencode',
1623
+ 'cursor': 'cursor',
1624
+ 'aider': 'aider',
1625
+ 'codex': 'codex',
1626
+ };
1627
+
1628
+ export function resolveAgent(alias: string): string {
1629
+ return AGENT_ALIASES[alias.toLowerCase()] || alias;
1630
+ }
1631
+
1632
+ export function spawnAgent(
1633
+ agent: string,
1634
+ wrapperDir: string,
1635
+ port: number,
1636
+ onExit: (code: number) => void
1637
+ ): ChildProcess {
1638
+ const resolvedAgent = resolveAgent(agent);
1639
+
1640
+ const env = {
1641
+ ...process.env,
1642
+ PATH: `${wrapperDir}:${process.env.PATH}`,
1643
+ VETO_PORT: String(port),
1644
+ VETO_ACTIVE: '1',
1645
+ };
1646
+
1647
+ const child = spawn(resolvedAgent, [], {
1648
+ env,
1649
+ stdio: 'inherit',
1650
+ shell: true,
1651
+ });
1652
+
1653
+ child.on('exit', (code) => onExit(code ?? 0));
1654
+ child.on('error', (err) => {
1655
+ console.error(`Failed to start ${resolvedAgent}: ${err.message}`);
1656
+ onExit(1);
1657
+ });
1658
+
1659
+ return child;
1660
+ }
1661
+ ```
1662
+
1663
+ ### ui/colors.ts
1664
+
1665
+ ```typescript
1666
+ // src/ui/colors.ts
1667
+
1668
+ const isTTY = process.stdout.isTTY && process.stderr.isTTY;
1669
+ const noColor = process.env.NO_COLOR !== undefined || process.env.TERM === 'dumb';
1670
+
1671
+ function color(code: string): string {
1672
+ return isTTY && !noColor ? code : '';
1673
+ }
1674
+
1675
+ export const COLORS = {
1676
+ success: color('\x1b[32m'),
1677
+ error: color('\x1b[31m'),
1678
+ warning: color('\x1b[33m'),
1679
+ info: color('\x1b[36m'),
1680
+ dim: color('\x1b[90m'),
1681
+ bold: color('\x1b[1m'),
1682
+ reset: color('\x1b[0m'),
1683
+ };
1684
+
1685
+ export const SYMBOLS = {
1686
+ success: '✓',
1687
+ error: '✗',
1688
+ blocked: '⛔',
1689
+ warning: '⚠',
1690
+ arrow: '→',
1691
+ bullet: '•',
1692
+ };
1693
+
1694
+ const SPINNER_FRAMES = ['◐', '◓', '◑', '◒'];
1695
+
1696
+ export function createSpinner(message: string): { stop: () => void } {
1697
+ if (!isTTY) {
1698
+ console.log(message);
1699
+ return { stop: () => {} };
1700
+ }
1701
+
1702
+ let i = 0;
1703
+ const interval = setInterval(() => {
1704
+ process.stdout.write(
1705
+ `\r${COLORS.dim}${SPINNER_FRAMES[i++ % 4]} ${message}${COLORS.reset}`
1706
+ );
1707
+ }, 100);
1708
+
1709
+ return {
1710
+ stop: () => {
1711
+ clearInterval(interval);
1712
+ process.stdout.write('\r\x1b[K'); // Clear line
1713
+ },
1714
+ };
1715
+ }
1716
+ ```
1717
+
1718
+ ### cli.ts (Main Entry Point)
1719
+
1720
+ ```typescript
1721
+ #!/usr/bin/env node
1722
+ // src/cli.ts
1723
+
1724
+ import { compile } from './compiler';
1725
+ import { VetoDaemon } from './wrapper/daemon';
1726
+ import { createWrapperDir, cleanupWrapperDir } from './wrapper/shims';
1727
+ import { spawnAgent, resolveAgent } from './wrapper/spawn';
1728
+ import { COLORS, SYMBOLS, createSpinner } from './ui/colors';
1729
+ import { Policy } from './types';
1730
+
1731
+ const VERSION = '0.1.0';
1732
+
1733
+ async function main() {
1734
+ const args = process.argv.slice(2);
1735
+
1736
+ // Handle flags
1737
+ if (args.includes('--help') || args.includes('-h') || args.length === 0) {
1738
+ printHelp();
1739
+ process.exit(0);
1740
+ }
1741
+
1742
+ if (args.includes('--version') || args.includes('-v')) {
1743
+ console.log(`veto-leash v${VERSION}`);
1744
+ process.exit(0);
1745
+ }
1746
+
1747
+ const command = args[0];
1748
+
1749
+ // Route commands
1750
+ switch (command) {
1751
+ case 'watch':
1752
+ await runWatchdog(args.slice(1).join(' '));
1753
+ break;
1754
+ case 'explain':
1755
+ await runExplain(args.slice(1).join(' '));
1756
+ break;
1757
+ case 'status':
1758
+ runStatus();
1759
+ break;
1760
+ case 'install':
1761
+ await runInstall(args[1]);
1762
+ break;
1763
+ case 'add':
1764
+ await runAdd(args.slice(1).join(' '));
1765
+ break;
1766
+ case 'clear':
1767
+ runClear();
1768
+ break;
1769
+ default:
1770
+ // Default: wrap agent
1771
+ await runWrapper(command, args.slice(1).join(' '));
1772
+ }
1773
+ }
1774
+
1775
+ async function runWrapper(agent: string, restriction: string) {
1776
+ if (!restriction) {
1777
+ console.error(`${COLORS.error}${SYMBOLS.error} Error: No restriction provided${COLORS.reset}\n`);
1778
+ console.log('Usage: leash <agent> "<restriction>"\n');
1779
+ console.log('Example: leash cc "don\'t delete test files"');
1780
+ process.exit(1);
1781
+ }
1782
+
1783
+ // Check for API key
1784
+ if (!process.env.GEMINI_API_KEY) {
1785
+ console.error(`${COLORS.error}${SYMBOLS.error} Error: GEMINI_API_KEY not set${COLORS.reset}\n`);
1786
+ console.log(' Get a free API key (15 requests/min, 1M tokens/month):');
1787
+ console.log(' https://aistudio.google.com/apikey\n');
1788
+ console.log(' Then run:');
1789
+ console.log(' export GEMINI_API_KEY="your-key"\n');
1790
+ process.exit(1);
1791
+ }
1792
+
1793
+ // Compile restriction
1794
+ const spinner = createSpinner('Compiling restriction...');
1795
+ let policy: Policy;
1796
+
1797
+ try {
1798
+ policy = await compile(restriction);
1799
+ spinner.stop();
1800
+ } catch (err) {
1801
+ spinner.stop();
1802
+ console.error(`${COLORS.error}${SYMBOLS.error} Error: Failed to compile restriction${COLORS.reset}\n`);
1803
+ console.log(` ${(err as Error).message}\n`);
1804
+ process.exit(1);
1805
+ }
1806
+
1807
+ // Print startup message
1808
+ console.log(`\n${COLORS.success}${SYMBOLS.success} veto-leash active${COLORS.reset}\n`);
1809
+ console.log(` ${COLORS.dim}Policy:${COLORS.reset} ${policy.description}`);
1810
+ console.log(` ${COLORS.dim}Action:${COLORS.reset} ${policy.action}\n`);
1811
+ console.log(` ${COLORS.dim}Protecting:${COLORS.reset}`);
1812
+ console.log(` ${policy.include.slice(0, 5).join(' ')}`);
1813
+ if (policy.include.length > 5) {
1814
+ console.log(` ${COLORS.dim}...and ${policy.include.length - 5} more${COLORS.reset}`);
1815
+ }
1816
+ if (policy.exclude.length > 0) {
1817
+ console.log(`\n ${COLORS.dim}Allowing (exceptions):${COLORS.reset}`);
1818
+ console.log(` ${policy.exclude.join(' ')}`);
1819
+ }
1820
+ console.log(`\n Press Ctrl+C to exit\n`);
1821
+
1822
+ // Start daemon
1823
+ const daemon = new VetoDaemon(policy, agent);
1824
+ const port = await daemon.start();
1825
+
1826
+ // Create wrapper scripts
1827
+ const wrapperDir = createWrapperDir(port, policy);
1828
+
1829
+ // Handle cleanup
1830
+ const cleanup = () => {
1831
+ daemon.stop();
1832
+ cleanupWrapperDir(wrapperDir);
1833
+ };
1834
+
1835
+ process.on('SIGINT', () => {
1836
+ cleanup();
1837
+ process.exit(0);
1838
+ });
1839
+
1840
+ process.on('SIGTERM', () => {
1841
+ cleanup();
1842
+ process.exit(0);
1843
+ });
1844
+
1845
+ // Spawn agent
1846
+ spawnAgent(agent, wrapperDir, port, (code) => {
1847
+ cleanup();
1848
+ process.exit(code);
1849
+ });
1850
+ }
1851
+
1852
+ async function runExplain(restriction: string) {
1853
+ if (!restriction) {
1854
+ console.error(`${COLORS.error}${SYMBOLS.error} Error: No restriction provided${COLORS.reset}`);
1855
+ process.exit(1);
1856
+ }
1857
+
1858
+ const spinner = createSpinner('Analyzing restriction...');
1859
+ const policy = await compile(restriction);
1860
+ spinner.stop();
1861
+
1862
+ console.log(`\n${COLORS.bold}Policy Preview${COLORS.reset}`);
1863
+ console.log('══════════════\n');
1864
+ console.log(`Restriction: "${restriction}"\n`);
1865
+ console.log(`Action: ${policy.action}\n`);
1866
+ console.log(`Patterns:`);
1867
+ console.log(` ${COLORS.dim}include:${COLORS.reset} ${policy.include.join(', ')}`);
1868
+ console.log(` ${COLORS.dim}exclude:${COLORS.reset} ${policy.exclude.join(', ') || '(none)'}`);
1869
+ console.log(`\nDescription: ${policy.description}`);
1870
+ console.log(`\nRun 'leash <agent> "${restriction}"' to enforce.\n`);
1871
+ }
1872
+
1873
+ async function runWatchdog(restriction: string) {
1874
+ console.log(`${COLORS.warning}${SYMBOLS.warning} Watchdog mode not yet implemented${COLORS.reset}`);
1875
+ console.log('Use wrapper mode: leash cc "' + restriction + '"');
1876
+ }
1877
+
1878
+ function runStatus() {
1879
+ console.log(`\n${COLORS.bold}veto-leash Status${COLORS.reset}`);
1880
+ console.log('═════════════════\n');
1881
+ console.log('No active sessions.\n');
1882
+ }
1883
+
1884
+ async function runInstall(agent: string) {
1885
+ console.log(`${COLORS.warning}${SYMBOLS.warning} Native install not yet implemented${COLORS.reset}`);
1886
+ console.log(`Use wrapper mode: leash ${agent} "<restriction>"`);
1887
+ }
1888
+
1889
+ async function runAdd(restriction: string) {
1890
+ console.log(`${COLORS.warning}${SYMBOLS.warning} Policy persistence not yet implemented${COLORS.reset}`);
1891
+ }
1892
+
1893
+ function runClear() {
1894
+ console.log(`${COLORS.success}${SYMBOLS.success} Cache cleared${COLORS.reset}`);
1895
+ }
1896
+
1897
+ function printHelp() {
1898
+ console.log(`
1899
+ ${COLORS.bold}veto-leash${COLORS.reset} — Semantic permissions for AI coding agents
1900
+
1901
+ ${COLORS.bold}USAGE${COLORS.reset}
1902
+ leash <agent> "<restriction>" Wrap agent with policy enforcement
1903
+ leash watch "<restriction>" Background filesystem protection
1904
+ leash explain "<restriction>" Preview what a restriction protects
1905
+ leash install <agent> Install native hooks/config
1906
+ leash status Show active sessions
1907
+ leash clear Clear policy cache
1908
+
1909
+ ${COLORS.bold}AGENTS${COLORS.reset}
1910
+ cc, claude-code Claude Code
1911
+ oc, opencode OpenCode
1912
+ cursor Cursor
1913
+ aider Aider
1914
+ <any> Any CLI command
1915
+
1916
+ ${COLORS.bold}EXAMPLES${COLORS.reset}
1917
+ leash cc "don't delete test files"
1918
+ leash opencode "protect .env"
1919
+ leash cursor "no database migrations"
1920
+ leash explain "don't touch src/core"
1921
+
1922
+ ${COLORS.bold}ENVIRONMENT${COLORS.reset}
1923
+ GEMINI_API_KEY Required. Get free at https://aistudio.google.com/apikey
1924
+
1925
+ ${COLORS.bold}MORE INFO${COLORS.reset}
1926
+ https://github.com/plaw-inc/veto-leash
1927
+ `);
1928
+ }
1929
+
1930
+ main().catch((err) => {
1931
+ console.error(`${COLORS.error}${SYMBOLS.error} Error: ${err.message}${COLORS.reset}`);
1932
+ process.exit(1);
1933
+ });
1934
+ ```
1935
+
1936
+ ### tsconfig.json
1937
+
1938
+ ```json
1939
+ {
1940
+ "compilerOptions": {
1941
+ "target": "ES2022",
1942
+ "module": "NodeNext",
1943
+ "moduleResolution": "NodeNext",
1944
+ "lib": ["ES2022"],
1945
+ "outDir": "./dist",
1946
+ "rootDir": "./src",
1947
+ "strict": true,
1948
+ "esModuleInterop": true,
1949
+ "skipLibCheck": true,
1950
+ "forceConsistentCasingInFileNames": true,
1951
+ "resolveJsonModule": true,
1952
+ "declaration": true,
1953
+ "declarationMap": true,
1954
+ "sourceMap": true
1955
+ },
1956
+ "include": ["src/**/*"],
1957
+ "exclude": ["node_modules", "dist"]
1958
+ }
1959
+ ```
1960
+
1961
+ ### .gitignore
1962
+
1963
+ ```
1964
+ node_modules/
1965
+ dist/
1966
+ *.log
1967
+ .env
1968
+ .DS_Store
1969
+ ```
1970
+
1971
+ ---
1972
+
1973
+ ## Dependencies
1974
+
1975
+ ```json
1976
+ {
1977
+ "name": "veto-leash",
1978
+ "version": "0.1.0",
1979
+ "description": "Semantic permissions for AI coding agents",
1980
+ "bin": { "leash": "./dist/cli.js" },
1981
+ "type": "module",
1982
+ "scripts": {
1983
+ "build": "tsc",
1984
+ "dev": "tsx src/cli.ts",
1985
+ "prepublishOnly": "npm run build"
1986
+ },
1987
+ "dependencies": {
1988
+ "@google/genai": "^1.0.0",
1989
+ "micromatch": "^4.0.8",
1990
+ "glob": "^11.0.0",
1991
+ "chokidar": "^4.0.3"
1992
+ },
1993
+ "devDependencies": {
1994
+ "typescript": "^5.7.0",
1995
+ "tsx": "^4.19.0",
1996
+ "@types/node": "^22.0.0",
1997
+ "@types/micromatch": "^4.0.9"
1998
+ },
1999
+ "engines": { "node": ">=20" },
2000
+ "keywords": ["ai", "agents", "permissions", "security", "claude", "opencode"],
2001
+ "author": "Plaw, Inc.",
2002
+ "license": "MIT",
2003
+ "repository": {
2004
+ "type": "git",
2005
+ "url": "https://github.com/plaw-inc/veto-leash"
2006
+ }
2007
+ }
2008
+
2009
+ ---
2010
+
2011
+ ## Quick Start
2012
+
2013
+ ```bash
2014
+ # Install
2015
+ npm install -g veto-leash
2016
+
2017
+ # Get free Gemini API key
2018
+ # https://aistudio.google.com/apikey
2019
+ export GEMINI_API_KEY="your-key"
2020
+
2021
+ # Use it
2022
+ leash cc "don't delete test files"
2023
+
2024
+ # Or install native hooks (zero overhead)
2025
+ leash install cc # Claude Code PreToolUse hooks
2026
+ leash install oc # OpenCode permission.bash rules
2027
+ leash install windsurf # Windsurf Cascade hooks
2028
+ leash install cursor # Cursor .cursorrules (guidance only)
2029
+ leash install aider # Aider .aider.conf.yml read-only files
2030
+
2031
+ # Add persistent policies
2032
+ leash add "don't delete test files" # Compiles + saves to all agents
2033
+ leash add "protect .env"
2034
+
2035
+ # Apply project-wide policies from .leash file
2036
+ leash init # Create .leash config
2037
+ leash sync cc # Apply .leash policies to Claude Code
2038
+
2039
+ # Background protection (any agent)
2040
+ leash watch "protect test files"
2041
+
2042
+ # View audit trail
2043
+ leash audit # Show blocked/allowed/restored actions
2044
+ leash audit --tail # Show last N entries
2045
+ leash audit --clear # Clear audit log
2046
+
2047
+ # Leash Cloud (coming soon)
2048
+ leash login # Authenticate with Leash Cloud
2049
+ leash cloud status # Show connection status
2050
+ ```
2051
+
2052
+ ## Summary
2053
+
2054
+ **veto-leash** is a semantic permission layer that sits between AI coding agents and your system. Describe restrictions in plain English; veto-leash enforces them with precision.
2055
+
2056
+ ```bash
2057
+ # Install
2058
+ npm install -g veto-leash
2059
+
2060
+ # Get free Gemini API key (optional - builtins work without it)
2061
+ # https://aistudio.google.com/apikey
2062
+ export GEMINI_API_KEY="your-key"
2063
+
2064
+ # Use it
2065
+ leash cc "don't delete test files"
2066
+
2067
+ # Or install native hooks (zero overhead)
2068
+ leash install cc # Claude Code PreToolUse hooks
2069
+ leash install oc # OpenCode permission.bash rules
2070
+ leash install windsurf # Windsurf Cascade hooks
2071
+ leash install cursor # Cursor .cursorrules (guidance only)
2072
+ leash install aider # Aider .aider.conf.yml read-only files
2073
+
2074
+ # Add persistent policies
2075
+ leash add "don't delete test files" # Compiles + saves to all agents
2076
+ leash add "protect .env"
2077
+
2078
+ # Apply project-wide policies from .leash file
2079
+ leash init # Create .leash config
2080
+ leash sync cc # Apply .leash policies to Claude Code
2081
+
2082
+ # Background protection (any agent)
2083
+ leash watch "protect test files"
2084
+
2085
+ # View audit trail
2086
+ leash audit # Show blocked/allowed/restored actions
2087
+ leash audit --tail # Show last N entries
2088
+ leash audit --clear # Clear audit log
2089
+
2090
+ # Leash Cloud (coming soon)
2091
+ leash login # Authenticate with Leash Cloud
2092
+ leash cloud status # Show connection status
2093
+ ```
2094
+
2095
+ ---
2096
+
2097
+ ## What We Actually Built
2098
+
2099
+ ### Files Created (18 new files)
2100
+
2101
+ ```
2102
+ src/
2103
+ ├── audit/index.ts # JSON Lines audit logging
2104
+ ├── cloud/index.ts # Leash Cloud stubs
2105
+ ├── config/
2106
+ │ ├── loader.ts # .leash file parsing
2107
+ │ └── schema.ts # YAML schema + validation
2108
+ ├── native/
2109
+ │ ├── aider.ts # .aider.conf.yml integration
2110
+ │ ├── claude-code.ts # PreToolUse hooks
2111
+ │ ├── cursor.ts # .cursorrules generation
2112
+ │ ├── index.ts # Unified agent registry
2113
+ │ ├── opencode.ts # permission.bash rules
2114
+ │ └── windsurf.ts # Cascade hooks
2115
+ ├── watchdog/
2116
+ │ ├── index.ts # Orchestrator
2117
+ │ ├── restore.ts # File restoration
2118
+ │ ├── snapshot.ts # File stashing
2119
+ │ └── watcher.ts # chokidar setup
2120
+ └── wrapper/
2121
+ └── shims.ts # Unix + Windows shims
2122
+ ```
2123
+
2124
+ ### CLI Commands (14 total, vs 9 planned)
2125
+
2126
+ ```
2127
+ leash <agent> "<restriction>" # Wrapper mode
2128
+ leash watch "<restriction>" # Watchdog mode
2129
+ leash explain "<restriction>" # Preview policy
2130
+ leash add "<restriction>" # Save policy
2131
+ leash init # Create .leash config
2132
+ leash sync [agent] # Apply .leash policies
2133
+ leash install <agent> # Native install
2134
+ leash uninstall <agent> # Remove hooks/config
2135
+ leash list # Show saved policies
2136
+ leash audit [--tail] [--clear] # View/clear audit log
2137
+ leash login # Leash Cloud auth
2138
+ leash cloud status # Cloud connection status
2139
+ leash status # Show active sessions
2140
+ leash clear # Clear compilation cache
2141
+ ```
2142
+
2143
+ ### Agent Support (7 native integrations, wrapper for all others)
2144
+
2145
+ | Agent | Native | Notes |
2146
+ |-------|--------|-------|
2147
+ | Claude Code | ✅ PreToolUse hooks | Zero overhead |
2148
+ | Windsurf | ✅ Cascade hooks | Full support |
2149
+ | OpenCode | ✅ permission.bash | Full support |
2150
+ | Cursor | .cursorrules | Guidance only |
2151
+ | Aider | .aider.conf.yml | Read-only files |
2152
+ | Codex CLI | watchdog | OS sandbox |
2153
+ | GitHub Copilot | wrapper | No hooks |
2154
+ | Any CLI tool | wrapper | PATH-based interception |
2155
+
2156
+ ### Key Features Delivered
2157
+
2158
+ ✅ **Three Enforcement Modes**
2159
+ - Wrapper: PATH hijacking + TCP daemon
2160
+ - Watchdog: chokidar monitoring + auto-restore
2161
+ - Native: Agent-specific hooks (CC, Windsurf, OC) + guidance (Cursor, Aider)
2162
+
2163
+ ✅ **Project Configuration**
2164
+ - `.leash` YAML files for team-wide policies
2165
+ - `leash init` creates template config
2166
+ - `leash sync <agent>` applies policies to specific agent
2167
+
2168
+ ✅ **Audit Logging**
2169
+ - JSON Lines format in `~/.config/veto-leash/audit.jsonl`
2170
+ - `leash audit` shows blocked/allowed/restored actions
2171
+ - `--tail` flag for recent entries, `--clear` to wipe
2172
+
2173
+ ✅ **Leash Cloud Hooks (Stubbed)**
2174
+ - `leash login` auth endpoint stub
2175
+ - `leash cloud status` connection check
2176
+ - Ready for future team sync and model credits
2177
+
2178
+ ✅ **Platform Support**
2179
+ - macOS: Unix shims with netcat flags
2180
+ - Linux/Windows: PowerShell shims via platform detection
2181
+ - Cross-platform path normalization (backslashes → slashes)
2182
+
2183
+ ### Verdict: **Plan Accuracy: ~95%**
2184
+
2185
+ The core value proposition is fully delivered:
2186
+
2187
+ ```bash
2188
+ leash cc "don't delete test files"
2189
+ ```
2190
+
2191
+ Works. Blocks. Shows message. Ship faster. Sleep better.
2192
+
2193
+
2194
+ **Let's ship it.**