jettypod 4.4.54 → 4.4.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Global Guardrails Hook
4
+ *
5
+ * Enforces actions that are ALWAYS allowed and ALWAYS blocked regardless of
6
+ * skill or context. Runs before any contextual/skill-specific validators.
7
+ *
8
+ * Claude Code PreToolUse Hook
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ // Debug mode: set GUARDRAILS_DEBUG=1 to enable verbose logging
15
+ const DEBUG = process.env.GUARDRAILS_DEBUG === '1';
16
+
17
+ /**
18
+ * Structured debug logger - outputs to stderr to not interfere with JSON response
19
+ */
20
+ function debug(category, data) {
21
+ if (!DEBUG) return;
22
+ const timestamp = new Date().toISOString();
23
+ const logEntry = { timestamp, category, ...data };
24
+ console.error(`[guardrails] ${JSON.stringify(logEntry)}`);
25
+ }
26
+
27
+ // Read hook input from stdin
28
+ let input = '';
29
+ process.stdin.on('data', chunk => input += chunk);
30
+ process.stdin.on('end', () => {
31
+ try {
32
+ const hookInput = JSON.parse(input);
33
+ debug('input', {
34
+ tool: hookInput.tool_name,
35
+ cwd: hookInput.cwd,
36
+ active_worktree: hookInput.active_worktree_path,
37
+ branch: hookInput.current_branch
38
+ });
39
+
40
+ const result = evaluateRequest(hookInput);
41
+
42
+ debug('decision', {
43
+ tool: hookInput.tool_name,
44
+ allowed: result.allowed,
45
+ message: result.message || null
46
+ });
47
+
48
+ if (result.allowed) {
49
+ allow();
50
+ } else {
51
+ deny(result.message, result.hint);
52
+ }
53
+ } catch (err) {
54
+ // On parse error, allow (fail open)
55
+ debug('error', { type: 'parse', message: err.message });
56
+ console.error('Hook error:', err.message);
57
+ allow();
58
+ }
59
+ });
60
+
61
+ /**
62
+ * Evaluate the request and return allow/deny decision
63
+ */
64
+ function evaluateRequest(hookInput) {
65
+ const { tool_name, tool_input, cwd, active_worktree_path, current_branch } = hookInput;
66
+
67
+ // ALWAYS ALLOW: Read operations
68
+ if (tool_name === 'Read' || tool_name === 'Glob' || tool_name === 'Grep') {
69
+ return { allowed: true };
70
+ }
71
+
72
+ // Handle Bash commands
73
+ if (tool_name === 'Bash') {
74
+ return evaluateBashCommand(tool_input.command || '', current_branch, cwd);
75
+ }
76
+
77
+ // Handle Write/Edit operations
78
+ if (tool_name === 'Write' || tool_name === 'Edit') {
79
+ return evaluateWriteOperation(tool_input.file_path || '', active_worktree_path, cwd);
80
+ }
81
+
82
+ // Default: allow unknown tools
83
+ return { allowed: true };
84
+ }
85
+
86
+ /**
87
+ * Evaluate Bash command against global rules
88
+ */
89
+ function evaluateBashCommand(command, currentBranch, cwd) {
90
+ // BLOCKED: Force push
91
+ if (/git\s+push\s+.*--force/.test(command) || /git\s+push\s+-f\b/.test(command)) {
92
+ return {
93
+ allowed: false,
94
+ message: 'Force push is blocked',
95
+ hint: 'Force pushing can destroy history. Use regular push or create a PR.'
96
+ };
97
+ }
98
+
99
+ // BLOCKED: Direct commit to main
100
+ if (/git\s+commit\b/.test(command) && currentBranch === 'main') {
101
+ return {
102
+ allowed: false,
103
+ message: 'Direct commits to main are blocked',
104
+ hint: 'Use jettypod work start to create a feature branch first.'
105
+ };
106
+ }
107
+
108
+ // BLOCKED: Manual worktree creation
109
+ if (/git\s+worktree\s+add\b/.test(command)) {
110
+ return {
111
+ allowed: false,
112
+ message: 'Manual worktree creation is blocked',
113
+ hint: 'Use jettypod work start to create worktrees.'
114
+ };
115
+ }
116
+
117
+ // BLOCKED: Manual branch creation
118
+ if (/git\s+checkout\s+-b\b/.test(command) || /git\s+branch\s+(?!-d|-D)/.test(command)) {
119
+ return {
120
+ allowed: false,
121
+ message: 'Manual branch creation is blocked',
122
+ hint: 'Use jettypod work start to create branches.'
123
+ };
124
+ }
125
+
126
+ // BLOCKED: Direct SQL mutation to work.db
127
+ if (/sqlite3\s+.*work\.db/.test(command)) {
128
+ const sqlCommand = command.toLowerCase();
129
+ // Allow SELECT, block mutations
130
+ if (/\b(update|insert|delete|drop|alter|create)\b/i.test(sqlCommand)) {
131
+ return {
132
+ allowed: false,
133
+ message: 'Direct database mutations are blocked',
134
+ hint: 'Use jettypod CLI commands to modify work items.'
135
+ };
136
+ }
137
+ }
138
+
139
+ // BLOCKED: Merge from inside worktree (causes CWD corruption)
140
+ if (/jettypod\s+(work|tests)\s+merge/.test(command)) {
141
+ if (cwd && /\.jettypod-work\//.test(cwd)) {
142
+ return {
143
+ allowed: false,
144
+ message: 'Cannot merge from inside a worktree. CWD would be deleted.',
145
+ hint: 'Run: cd /Users/erikspangenberg/jettypod-source && jettypod work merge <id>'
146
+ };
147
+ }
148
+ }
149
+
150
+ // ALLOWED: Git read-only commands
151
+ if (/git\s+(status|log|diff|show|branch\s*$|remote|fetch)\b/.test(command)) {
152
+ return { allowed: true };
153
+ }
154
+
155
+ // ALLOWED: Jettypod read-only commands
156
+ if (/jettypod\s+(backlog|status|work\s+status|impact)\b/.test(command)) {
157
+ return { allowed: true };
158
+ }
159
+
160
+ // Default: allow other commands
161
+ return { allowed: true };
162
+ }
163
+
164
+ /**
165
+ * Find the jettypod database path
166
+ * @param {string} cwd - Starting directory
167
+ * @returns {string|null} Database path or null
168
+ */
169
+ function findDatabasePath(cwd) {
170
+ let dir = cwd;
171
+ while (dir !== path.dirname(dir)) {
172
+ const dbPath = path.join(dir, '.jettypod', 'work.db');
173
+ if (fs.existsSync(dbPath)) {
174
+ return dbPath;
175
+ }
176
+ dir = path.dirname(dir);
177
+ }
178
+ return null;
179
+ }
180
+
181
+ /**
182
+ * Get the active worktree path from database
183
+ * @param {string} cwd - Current working directory
184
+ * @returns {string|null} Active worktree path or null
185
+ */
186
+ function getActiveWorktreePathFromDB(cwd) {
187
+ const dbPath = findDatabasePath(cwd);
188
+ if (!dbPath) {
189
+ debug('db_lookup', { status: 'no_db_found', cwd });
190
+ return null;
191
+ }
192
+
193
+ try {
194
+ // Try better-sqlite3 first
195
+ const sqlite3 = require('better-sqlite3');
196
+ const db = sqlite3(dbPath, { readonly: true });
197
+ const row = db.prepare(
198
+ `SELECT worktree_path FROM worktrees WHERE status = 'active' LIMIT 1`
199
+ ).get();
200
+ db.close();
201
+ const result = row ? row.worktree_path : null;
202
+ debug('db_lookup', { status: 'success', method: 'better-sqlite3', worktree: result });
203
+ return result;
204
+ } catch (err) {
205
+ debug('db_lookup', { status: 'fallback', reason: err.message });
206
+ // Fall back to CLI
207
+ const { spawnSync } = require('child_process');
208
+ const result = spawnSync('sqlite3', [
209
+ dbPath,
210
+ `SELECT worktree_path FROM worktrees WHERE status = 'active' LIMIT 1`
211
+ ], { encoding: 'utf-8' });
212
+
213
+ if (result.error || result.status !== 0) {
214
+ debug('db_lookup', { status: 'cli_failed', error: result.error?.message || 'non-zero exit' });
215
+ return null;
216
+ }
217
+ const worktree = result.stdout.trim() || null;
218
+ debug('db_lookup', { status: 'success', method: 'sqlite3-cli', worktree });
219
+ return worktree;
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Evaluate Write/Edit operation against global rules
225
+ */
226
+ function evaluateWriteOperation(filePath, inputWorktreePath, cwd) {
227
+ // Query database for active worktree, fall back to input if provided
228
+ const activeWorktreePath = getActiveWorktreePathFromDB(cwd) || inputWorktreePath;
229
+
230
+ // Normalize paths
231
+ const normalizedPath = path.resolve(cwd || '.', filePath);
232
+ const normalizedWorktree = activeWorktreePath ? path.resolve(cwd || '.', activeWorktreePath) : null;
233
+
234
+ debug('write_eval', {
235
+ filePath,
236
+ normalizedPath,
237
+ activeWorktreePath,
238
+ normalizedWorktree
239
+ });
240
+
241
+ // BLOCKED: Protected files (skills, hooks)
242
+ const protectedPatterns = [
243
+ /\.claude\/skills\//i,
244
+ /claude-hooks\//i,
245
+ /\.jettypod\/hooks\//i
246
+ ];
247
+
248
+ for (const pattern of protectedPatterns) {
249
+ if (pattern.test(normalizedPath) || pattern.test(filePath)) {
250
+ return {
251
+ allowed: false,
252
+ message: 'Protected file - cannot modify',
253
+ hint: 'Skill and hook files are protected. Modify them through proper channels.'
254
+ };
255
+ }
256
+ }
257
+
258
+ // Check if path is in a worktree
259
+ const isInWorktree = /\.jettypod-work\//.test(filePath) || /\.jettypod-work\//.test(normalizedPath);
260
+
261
+ if (isInWorktree) {
262
+ // If we have an active worktree, check if this write is to it
263
+ if (activeWorktreePath) {
264
+ // SECURITY: Only use normalized path comparison to prevent traversal attacks
265
+ // The filePath.includes() check was vulnerable to "../" escapes
266
+ const isActiveWorktree = normalizedPath.startsWith(normalizedWorktree + path.sep) ||
267
+ normalizedPath === normalizedWorktree;
268
+
269
+ if (isActiveWorktree) {
270
+ return { allowed: true };
271
+ } else {
272
+ return {
273
+ allowed: false,
274
+ message: 'Not your active worktree',
275
+ hint: 'You can only write to your currently active worktree.'
276
+ };
277
+ }
278
+ }
279
+ // No active worktree tracked but path looks like worktree - block
280
+ return {
281
+ allowed: false,
282
+ message: 'Not your active worktree',
283
+ hint: 'You can only write to your currently active worktree.'
284
+ };
285
+ }
286
+
287
+ // BLOCKED: Write to main repo (not in worktree)
288
+ return {
289
+ allowed: false,
290
+ message: 'Cannot write to main repository',
291
+ hint: 'Use jettypod work start to create a worktree first.'
292
+ };
293
+ }
294
+
295
+ /**
296
+ * Allow the action
297
+ */
298
+ function allow() {
299
+ console.log(JSON.stringify({
300
+ hookSpecificOutput: {
301
+ hookEventName: "PreToolUse",
302
+ permissionDecision: "allow"
303
+ }
304
+ }));
305
+ process.exit(0);
306
+ }
307
+
308
+ /**
309
+ * Deny the action with explanation
310
+ */
311
+ function deny(message, hint) {
312
+ const reason = `āŒ ${message}\n\nšŸ’” Hint: ${hint || 'Check your action.'}`;
313
+
314
+ console.log(JSON.stringify({
315
+ hookSpecificOutput: {
316
+ hookEventName: "PreToolUse",
317
+ permissionDecision: "deny",
318
+ permissionDecisionReason: reason
319
+ }
320
+ }));
321
+ process.exit(0);
322
+ }
package/jettypod.js CHANGED
@@ -785,6 +785,14 @@ async function initializeProject() {
785
785
  fs.chmodSync(enforceHookDest, 0o755);
786
786
  }
787
787
 
788
+ // Install global-guardrails hook
789
+ const guardrailsHookSource = path.join(__dirname, 'claude-hooks', 'global-guardrails.js');
790
+ const guardrailsHookDest = path.join('.jettypod', 'hooks', 'global-guardrails.js');
791
+ if (fs.existsSync(guardrailsHookSource)) {
792
+ fs.copyFileSync(guardrailsHookSource, guardrailsHookDest);
793
+ fs.chmodSync(guardrailsHookDest, 0o755);
794
+ }
795
+
788
796
  // Create Claude Code settings
789
797
  if (!fs.existsSync('.claude')) {
790
798
  fs.mkdirSync('.claude', { recursive: true });
@@ -800,15 +808,24 @@ async function initializeProject() {
800
808
  PreToolUse: [
801
809
  {
802
810
  matcher: 'Edit',
803
- hooks: [{ type: 'command', command: '.jettypod/hooks/protect-claude-md.js' }]
811
+ hooks: [
812
+ { type: 'command', command: '.jettypod/hooks/global-guardrails.js' },
813
+ { type: 'command', command: '.jettypod/hooks/protect-claude-md.js' }
814
+ ]
804
815
  },
805
816
  {
806
817
  matcher: 'Write',
807
- hooks: [{ type: 'command', command: '.jettypod/hooks/protect-claude-md.js' }]
818
+ hooks: [
819
+ { type: 'command', command: '.jettypod/hooks/global-guardrails.js' },
820
+ { type: 'command', command: '.jettypod/hooks/protect-claude-md.js' }
821
+ ]
808
822
  },
809
823
  {
810
824
  matcher: 'Bash',
811
- hooks: [{ type: 'command', command: '.jettypod/hooks/enforce-skill-activation.js' }]
825
+ hooks: [
826
+ { type: 'command', command: '.jettypod/hooks/global-guardrails.js' },
827
+ { type: 'command', command: '.jettypod/hooks/enforce-skill-activation.js' }
828
+ ]
812
829
  }
813
830
  ]
814
831
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jettypod",
3
- "version": "4.4.54",
3
+ "version": "4.4.56",
4
4
  "description": "AI-powered development workflow manager with TDD, BDD, and automatic test generation",
5
5
  "main": "jettypod.js",
6
6
  "bin": {