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.
- package/claude-hooks/global-guardrails.js +322 -0
- package/jettypod.js +20 -3
- package/package.json +1 -1
|
@@ -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: [
|
|
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: [
|
|
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: [
|
|
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
|
}
|