gramatr 0.3.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 (50) hide show
  1. package/CLAUDE.md +18 -0
  2. package/README.md +78 -0
  3. package/bin/clean-legacy-install.ts +28 -0
  4. package/bin/get-token.py +3 -0
  5. package/bin/gmtr-login.ts +547 -0
  6. package/bin/gramatr.js +33 -0
  7. package/bin/gramatr.ts +248 -0
  8. package/bin/install.ts +756 -0
  9. package/bin/render-claude-hooks.ts +16 -0
  10. package/bin/statusline.ts +437 -0
  11. package/bin/uninstall.ts +289 -0
  12. package/bin/version-sync.ts +46 -0
  13. package/codex/README.md +28 -0
  14. package/codex/hooks/session-start.ts +73 -0
  15. package/codex/hooks/stop.ts +34 -0
  16. package/codex/hooks/user-prompt-submit.ts +76 -0
  17. package/codex/install.ts +99 -0
  18. package/codex/lib/codex-hook-utils.ts +48 -0
  19. package/codex/lib/codex-install-utils.ts +123 -0
  20. package/core/feedback.ts +55 -0
  21. package/core/formatting.ts +167 -0
  22. package/core/install.ts +114 -0
  23. package/core/installer-cli.ts +122 -0
  24. package/core/migration.ts +244 -0
  25. package/core/routing.ts +98 -0
  26. package/core/session.ts +202 -0
  27. package/core/targets.ts +292 -0
  28. package/core/types.ts +178 -0
  29. package/core/version.ts +2 -0
  30. package/gemini/README.md +95 -0
  31. package/gemini/hooks/session-start.ts +72 -0
  32. package/gemini/hooks/stop.ts +30 -0
  33. package/gemini/hooks/user-prompt-submit.ts +74 -0
  34. package/gemini/install.ts +272 -0
  35. package/gemini/lib/gemini-hook-utils.ts +63 -0
  36. package/gemini/lib/gemini-install-utils.ts +169 -0
  37. package/hooks/GMTRPromptEnricher.hook.ts +650 -0
  38. package/hooks/GMTRRatingCapture.hook.ts +198 -0
  39. package/hooks/GMTRSecurityValidator.hook.ts +399 -0
  40. package/hooks/GMTRToolTracker.hook.ts +181 -0
  41. package/hooks/StopOrchestrator.hook.ts +78 -0
  42. package/hooks/gmtr-tool-tracker-utils.ts +105 -0
  43. package/hooks/lib/gmtr-hook-utils.ts +771 -0
  44. package/hooks/lib/identity.ts +227 -0
  45. package/hooks/lib/notify.ts +46 -0
  46. package/hooks/lib/paths.ts +104 -0
  47. package/hooks/lib/transcript-parser.ts +452 -0
  48. package/hooks/session-end.hook.ts +168 -0
  49. package/hooks/session-start.hook.ts +490 -0
  50. package/package.json +54 -0
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * GMTRRatingCapture.hook.ts — gramatr-native Rating Capture (UserPromptSubmit)
4
+ *
5
+ * Captures explicit ratings (1-10 pattern) from user prompts and writes
6
+ * to $GMTR_DIR/.state/ratings.jsonl for sparkline consumption.
7
+ *
8
+ * TRIGGER: UserPromptSubmit
9
+ *
10
+ * This is the gramatr-native replacement for PAI's RatingCapture.hook.ts.
11
+ * Key differences:
12
+ * - No Haiku inference for implicit sentiment (server-side via GMTR later)
13
+ * - No PAI MEMORY directory writes — uses gramatr .state/ directory
14
+ * - No PAI lib dependencies (inference, identity, learning-utils, etc.)
15
+ * - Self-contained: only depends on ./lib/paths
16
+ *
17
+ * SIDE EFFECTS:
18
+ * - Writes to: $GMTR_DIR/.state/ratings.jsonl
19
+ * - macOS notification for low ratings (<= 3)
20
+ *
21
+ * PERFORMANCE:
22
+ * - Explicit rating path: <10ms (regex only, no inference)
23
+ * - No-match path: <5ms (exits immediately)
24
+ */
25
+
26
+ import { appendFileSync, mkdirSync, existsSync } from 'fs';
27
+ import { join, dirname } from 'path';
28
+ import { getGmtrDir } from './lib/paths';
29
+
30
+ // ── Types ──
31
+
32
+ interface HookInput {
33
+ session_id: string;
34
+ prompt?: string;
35
+ user_prompt?: string;
36
+ transcript_path: string;
37
+ hook_event_name: string;
38
+ }
39
+
40
+ interface RatingEntry {
41
+ timestamp: string;
42
+ rating: number;
43
+ session_id: string;
44
+ client_type?: string;
45
+ agent_name?: string;
46
+ comment?: string;
47
+ source?: 'implicit';
48
+ sentiment_summary?: string;
49
+ confidence?: number;
50
+ }
51
+
52
+ // ── Constants ──
53
+
54
+ const BASE_DIR = getGmtrDir();
55
+ const STATE_DIR = join(BASE_DIR, '.state');
56
+ const RATINGS_FILE = join(STATE_DIR, 'ratings.jsonl');
57
+
58
+ // ── Stdin Reader ──
59
+
60
+ async function readStdinWithTimeout(timeout: number = 3000): Promise<string> {
61
+ return new Promise((resolve, reject) => {
62
+ let data = '';
63
+ const timer = setTimeout(() => reject(new Error('Timeout')), timeout);
64
+ process.stdin.on('data', (chunk) => { data += chunk.toString(); });
65
+ process.stdin.on('end', () => { clearTimeout(timer); resolve(data); });
66
+ process.stdin.on('error', (err) => { clearTimeout(timer); reject(err); });
67
+ });
68
+ }
69
+
70
+ // ── Explicit Rating Detection ──
71
+
72
+ /**
73
+ * Parse explicit rating pattern from prompt.
74
+ * Matches: "7", "8 - good work", "6: needs work", "9 excellent", "10!"
75
+ * Rejects: "3 items", "5 things to fix", "7th thing"
76
+ */
77
+ function parseExplicitRating(prompt: string): { rating: number; comment?: string } | null {
78
+ const trimmed = prompt.trim();
79
+ const ratingPattern = /^(10|[1-9])(?:\s*[-:]\s*|\s+)?(.*)$/;
80
+ const match = trimmed.match(ratingPattern);
81
+ if (!match) return null;
82
+
83
+ const rating = parseInt(match[1], 10);
84
+ const comment = match[2]?.trim() || undefined;
85
+
86
+ if (rating < 1 || rating > 10) return null;
87
+
88
+ // Reject if comment starts with words indicating a sentence, not a rating
89
+ if (comment) {
90
+ const sentenceStarters = /^(items?|things?|steps?|files?|lines?|bugs?|issues?|errors?|times?|minutes?|hours?|days?|seconds?|percent|%|th\b|st\b|nd\b|rd\b|of\b|in\b|at\b|to\b|the\b|a\b|an\b)/i;
91
+ if (sentenceStarters.test(comment)) return null;
92
+ }
93
+
94
+ return { rating, comment };
95
+ }
96
+
97
+ // ── Write Rating ──
98
+
99
+ function writeRating(entry: RatingEntry): void {
100
+ if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
101
+ appendFileSync(RATINGS_FILE, JSON.stringify(entry) + '\n', 'utf-8');
102
+ console.error(`[GMTRRatingCapture] Wrote rating ${entry.rating} to ${RATINGS_FILE}`);
103
+ }
104
+
105
+ // ── Push to GMTR Server (fire-and-forget) ──
106
+
107
+ function pushToServer(entry: RatingEntry): void {
108
+ const gmtrUrl = process.env.GMTR_URL || 'https://api.gramatr.com/mcp';
109
+ const apiBase = gmtrUrl.replace(/\/mcp$/, '/api/v1');
110
+
111
+ fetch(`${apiBase}/feedback`, {
112
+ method: 'POST',
113
+ headers: { 'Content-Type': 'application/json' },
114
+ body: JSON.stringify({
115
+ feedback_type: 'rating',
116
+ rating: entry.rating,
117
+ task_description: entry.comment || `Session rating ${entry.rating}/10`,
118
+ session_id: entry.session_id,
119
+ client_type: entry.client_type || 'claude_code',
120
+ agent_name: entry.agent_name || 'Claude Code',
121
+ }),
122
+ signal: AbortSignal.timeout(5000),
123
+ })
124
+ .then((res) => {
125
+ if (res.ok) {
126
+ console.error(`[GMTRRatingCapture] Pushed rating ${entry.rating} to server`);
127
+ } else {
128
+ console.error(`[GMTRRatingCapture] Server push failed: HTTP ${res.status}`);
129
+ }
130
+ })
131
+ .catch(() => {
132
+ // Server push is best-effort — don't block on failure
133
+ });
134
+ }
135
+
136
+ // ── Notify (macOS native — fire and forget) ──
137
+
138
+ function notifyLowRating(rating: number, comment?: string): void {
139
+ try {
140
+ const msg = comment
141
+ ? `Rating ${rating}/10: ${comment}`
142
+ : `Rating ${rating}/10 received`;
143
+ const { spawn } = require('child_process');
144
+ spawn('osascript', ['-e', `display notification "${msg}" with title "gramatr" subtitle "Low Rating Alert"`], {
145
+ stdio: 'ignore',
146
+ detached: true,
147
+ }).unref();
148
+ } catch {
149
+ // Notification is best-effort
150
+ }
151
+ }
152
+
153
+ // ── Main ──
154
+
155
+ async function main() {
156
+ try {
157
+ const input = await readStdinWithTimeout();
158
+ const data: HookInput = JSON.parse(input);
159
+ const prompt = data.prompt || data.user_prompt || '';
160
+
161
+ if (!prompt || prompt.trim().length < 1) {
162
+ process.exit(0);
163
+ }
164
+
165
+ const result = parseExplicitRating(prompt);
166
+ if (!result) {
167
+ // No explicit rating detected — exit silently
168
+ // Implicit sentiment analysis will be handled server-side via GMTR later
169
+ process.exit(0);
170
+ }
171
+
172
+ console.error(`[GMTRRatingCapture] Explicit rating: ${result.rating}${result.comment ? ` - ${result.comment}` : ''}`);
173
+
174
+ const entry: RatingEntry = {
175
+ timestamp: new Date().toISOString(),
176
+ rating: result.rating,
177
+ session_id: data.session_id,
178
+ client_type: 'claude_code',
179
+ agent_name: 'Claude Code',
180
+ };
181
+ if (result.comment) entry.comment = result.comment;
182
+
183
+ writeRating(entry);
184
+ pushToServer(entry);
185
+
186
+ // Alert on critically low ratings
187
+ if (result.rating <= 3) {
188
+ notifyLowRating(result.rating, result.comment);
189
+ }
190
+
191
+ process.exit(0);
192
+ } catch (err) {
193
+ console.error(`[GMTRRatingCapture] Error: ${err}`);
194
+ process.exit(0);
195
+ }
196
+ }
197
+
198
+ main();
@@ -0,0 +1,399 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * GMTRSecurityValidator.hook.ts — gramatr-native Security Validation (PreToolUse)
4
+ *
5
+ * Validates Bash commands and file operations against security patterns
6
+ * before execution. Prevents destructive operations and protects sensitive files.
7
+ *
8
+ * TRIGGER: PreToolUse (matcher: Bash, Edit, Write, Read)
9
+ *
10
+ * This is the gramatr-native replacement for PAI's SecurityValidator.hook.ts.
11
+ * Key differences:
12
+ * - Inline default patterns (no yaml dependency, no PAI skill dir)
13
+ * - Logs to $GMTR_DIR/.state/security/ (not PAI MEMORY/SECURITY/)
14
+ * - Self-contained: only depends on ./lib/paths
15
+ *
16
+ * OUTPUT:
17
+ * - stdout: JSON decision: {"continue": true} | {"decision": "ask", "message": "..."}
18
+ * - exit(0): Normal completion
19
+ * - exit(2): Hard block (catastrophic operation prevented)
20
+ *
21
+ * PERFORMANCE: <10ms blocking
22
+ */
23
+
24
+ import { writeFileSync, mkdirSync, existsSync } from 'fs';
25
+ import { join } from 'path';
26
+ import { homedir } from 'os';
27
+ import { getGmtrDir } from './lib/paths';
28
+
29
+ // ── Types ──
30
+
31
+ interface HookInput {
32
+ session_id: string;
33
+ tool_name: string;
34
+ tool_input: Record<string, unknown> | string;
35
+ }
36
+
37
+ interface Pattern {
38
+ pattern: string;
39
+ reason: string;
40
+ }
41
+
42
+ interface SecurityEvent {
43
+ timestamp: string;
44
+ session_id: string;
45
+ event_type: 'block' | 'confirm' | 'alert' | 'allow';
46
+ tool: string;
47
+ category: 'bash_command' | 'path_access';
48
+ target: string;
49
+ reason?: string;
50
+ action_taken: string;
51
+ }
52
+
53
+ // ── Inline Security Patterns (no yaml dependency) ──
54
+
55
+ const BLOCKED_COMMANDS: Pattern[] = [
56
+ { pattern: 'rm\\s+-rf\\s+/', reason: 'Recursive delete from root' },
57
+ { pattern: 'rm\\s+-rf\\s+~', reason: 'Recursive delete from home' },
58
+ { pattern: 'rm\\s+-rf\\s+\\$HOME', reason: 'Recursive delete from home' },
59
+ { pattern: 'rm\\s+-rf\\s+\\*', reason: 'Recursive delete wildcard' },
60
+ { pattern: 'mkfs\\.', reason: 'Filesystem format' },
61
+ { pattern: 'dd\\s+if=.*of=/dev/', reason: 'Direct disk write' },
62
+ { pattern: ':\\(\\)\\{\\s*:\\|:\\s*&\\s*\\}\\s*;\\s*:', reason: 'Fork bomb' },
63
+ { pattern: '>\\/dev\\/sd[a-z]', reason: 'Direct block device write' },
64
+ { pattern: 'chmod\\s+-R\\s+777\\s+/', reason: 'Recursive world-writable from root' },
65
+ { pattern: 'chown\\s+-R.*\\s+/', reason: 'Recursive ownership change from root' },
66
+ ];
67
+
68
+ const CONFIRM_COMMANDS: Pattern[] = [
69
+ { pattern: 'git\\s+push\\s+--force', reason: 'Force push — may overwrite remote history' },
70
+ { pattern: 'git\\s+push\\s+-f\\b', reason: 'Force push — may overwrite remote history' },
71
+ { pattern: 'git\\s+reset\\s+--hard', reason: 'Hard reset — discards uncommitted changes' },
72
+ { pattern: 'git\\s+clean\\s+-fd', reason: 'Clean untracked files' },
73
+ { pattern: 'git\\s+checkout\\s+--\\s+\\.', reason: 'Discard all working directory changes' },
74
+ { pattern: 'npm\\s+publish', reason: 'Publishing package to registry' },
75
+ { pattern: 'docker\\s+system\\s+prune', reason: 'Docker prune — removes stopped containers and images' },
76
+ { pattern: 'kubectl\\s+delete', reason: 'Kubernetes resource deletion' },
77
+ { pattern: 'DROP\\s+(TABLE|DATABASE|INDEX)', reason: 'Database drop operation' },
78
+ { pattern: 'TRUNCATE\\s+TABLE', reason: 'Database truncate operation' },
79
+ ];
80
+
81
+ const ALERT_COMMANDS: Pattern[] = [
82
+ { pattern: '^sudo\\s+', reason: 'Elevated privileges' },
83
+ { pattern: 'curl.*\\|.*sh', reason: 'Piping curl to shell' },
84
+ { pattern: 'wget.*\\|.*sh', reason: 'Piping wget to shell' },
85
+ { pattern: 'eval\\s+', reason: 'Eval execution' },
86
+ ];
87
+
88
+ // Path patterns
89
+ const ZERO_ACCESS_PATHS = [
90
+ '~/.ssh',
91
+ '~/.gnupg',
92
+ '~/.aws/credentials',
93
+ '~/.config/gcloud/credentials.db',
94
+ '~/.netrc',
95
+ '/etc/shadow',
96
+ '/etc/sudoers',
97
+ ];
98
+
99
+ const READ_ONLY_PATHS = [
100
+ '/etc/passwd',
101
+ '/etc/hosts',
102
+ '~/.bashrc',
103
+ '~/.zshrc',
104
+ '~/.profile',
105
+ ];
106
+
107
+ const CONFIRM_WRITE_PATHS = [
108
+ '~/.claude/settings.json',
109
+ '~/.gitconfig',
110
+ '~/.npmrc',
111
+ ];
112
+
113
+ const NO_DELETE_PATHS = [
114
+ '~/.claude',
115
+ '~/.ssh',
116
+ '~/.gnupg',
117
+ ];
118
+
119
+ // ── Security Event Logging ──
120
+
121
+ const BASE_DIR = getGmtrDir();
122
+ const SECURITY_DIR = join(BASE_DIR, '.state', 'security');
123
+
124
+ function logSecurityEvent(event: SecurityEvent): void {
125
+ try {
126
+ if (!existsSync(SECURITY_DIR)) {
127
+ mkdirSync(SECURITY_DIR, { recursive: true });
128
+ }
129
+
130
+ const now = new Date();
131
+ const dateStr = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
132
+ const filename = `${event.event_type}-${dateStr}.json`;
133
+ const logPath = join(SECURITY_DIR, filename);
134
+
135
+ writeFileSync(logPath, JSON.stringify(event, null, 2));
136
+ } catch {
137
+ // Logging failure should not block operations
138
+ }
139
+ }
140
+
141
+ // ── Command Normalization ──
142
+
143
+ function stripEnvVarPrefix(command: string): string {
144
+ return command.replace(
145
+ /^\s*(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s]*)\s+)*/,
146
+ ''
147
+ );
148
+ }
149
+
150
+ // ── Pattern Matching ──
151
+
152
+ function matchesPattern(command: string, pattern: string): boolean {
153
+ try {
154
+ const regex = new RegExp(pattern, 'i');
155
+ return regex.test(command);
156
+ } catch {
157
+ return command.toLowerCase().includes(pattern.toLowerCase());
158
+ }
159
+ }
160
+
161
+ function expandPath(path: string): string {
162
+ if (path.startsWith('~')) {
163
+ return path.replace('~', homedir());
164
+ }
165
+ return path;
166
+ }
167
+
168
+ function matchesPathPattern(filePath: string, pattern: string): boolean {
169
+ const expandedPattern = expandPath(pattern);
170
+ const expandedPath = expandPath(filePath);
171
+
172
+ return expandedPath === expandedPattern ||
173
+ expandedPath.startsWith(expandedPattern.endsWith('/') ? expandedPattern : expandedPattern + '/');
174
+ }
175
+
176
+ // ── Validation ──
177
+
178
+ function validateBashCommand(command: string): { action: 'allow' | 'block' | 'confirm' | 'alert'; reason?: string } {
179
+ for (const p of BLOCKED_COMMANDS) {
180
+ if (matchesPattern(command, p.pattern)) {
181
+ return { action: 'block', reason: p.reason };
182
+ }
183
+ }
184
+ for (const p of CONFIRM_COMMANDS) {
185
+ if (matchesPattern(command, p.pattern)) {
186
+ return { action: 'confirm', reason: p.reason };
187
+ }
188
+ }
189
+ for (const p of ALERT_COMMANDS) {
190
+ if (matchesPattern(command, p.pattern)) {
191
+ return { action: 'alert', reason: p.reason };
192
+ }
193
+ }
194
+ return { action: 'allow' };
195
+ }
196
+
197
+ type PathAction = 'read' | 'write' | 'delete';
198
+
199
+ function validatePath(filePath: string, action: PathAction): { action: 'allow' | 'block' | 'confirm'; reason?: string } {
200
+ for (const p of ZERO_ACCESS_PATHS) {
201
+ if (matchesPathPattern(filePath, p)) {
202
+ return { action: 'block', reason: `Zero access path: ${p}` };
203
+ }
204
+ }
205
+
206
+ if (action === 'write' || action === 'delete') {
207
+ for (const p of READ_ONLY_PATHS) {
208
+ if (matchesPathPattern(filePath, p)) {
209
+ return { action: 'block', reason: `Read-only path: ${p}` };
210
+ }
211
+ }
212
+ }
213
+
214
+ if (action === 'write') {
215
+ for (const p of CONFIRM_WRITE_PATHS) {
216
+ if (matchesPathPattern(filePath, p)) {
217
+ return { action: 'confirm', reason: `Writing to protected file: ${p}` };
218
+ }
219
+ }
220
+ }
221
+
222
+ if (action === 'delete') {
223
+ for (const p of NO_DELETE_PATHS) {
224
+ if (matchesPathPattern(filePath, p)) {
225
+ return { action: 'block', reason: `Cannot delete protected path: ${p}` };
226
+ }
227
+ }
228
+ }
229
+
230
+ return { action: 'allow' };
231
+ }
232
+
233
+ // ── Tool Handlers ──
234
+
235
+ function handleBash(input: HookInput): void {
236
+ const rawCommand = typeof input.tool_input === 'string'
237
+ ? input.tool_input
238
+ : (input.tool_input?.command as string) || '';
239
+
240
+ if (!rawCommand) {
241
+ console.log(JSON.stringify({ continue: true }));
242
+ return;
243
+ }
244
+
245
+ const command = stripEnvVarPrefix(rawCommand);
246
+ const result = validateBashCommand(command);
247
+
248
+ switch (result.action) {
249
+ case 'block':
250
+ logSecurityEvent({
251
+ timestamp: new Date().toISOString(),
252
+ session_id: input.session_id,
253
+ event_type: 'block',
254
+ tool: 'Bash',
255
+ category: 'bash_command',
256
+ target: command.slice(0, 500),
257
+ reason: result.reason,
258
+ action_taken: 'Hard block - exit 2',
259
+ });
260
+ console.error(`[GMTR SECURITY] BLOCKED: ${result.reason}`);
261
+ process.exit(2);
262
+ break;
263
+
264
+ case 'confirm':
265
+ logSecurityEvent({
266
+ timestamp: new Date().toISOString(),
267
+ session_id: input.session_id,
268
+ event_type: 'confirm',
269
+ tool: 'Bash',
270
+ category: 'bash_command',
271
+ target: command.slice(0, 500),
272
+ reason: result.reason,
273
+ action_taken: 'Prompted user for confirmation',
274
+ });
275
+ console.log(JSON.stringify({
276
+ decision: 'ask',
277
+ message: `[GMTR SECURITY] ${result.reason}\n\nCommand: ${command.slice(0, 200)}\n\nProceed?`,
278
+ }));
279
+ break;
280
+
281
+ case 'alert':
282
+ logSecurityEvent({
283
+ timestamp: new Date().toISOString(),
284
+ session_id: input.session_id,
285
+ event_type: 'alert',
286
+ tool: 'Bash',
287
+ category: 'bash_command',
288
+ target: command.slice(0, 500),
289
+ reason: result.reason,
290
+ action_taken: 'Logged alert, allowed execution',
291
+ });
292
+ console.error(`[GMTR SECURITY] ALERT: ${result.reason}`);
293
+ console.log(JSON.stringify({ continue: true }));
294
+ break;
295
+
296
+ default:
297
+ console.log(JSON.stringify({ continue: true }));
298
+ }
299
+ }
300
+
301
+ function handlePathTool(input: HookInput, action: PathAction): void {
302
+ const filePath = typeof input.tool_input === 'string'
303
+ ? input.tool_input
304
+ : (input.tool_input?.file_path as string) || '';
305
+
306
+ if (!filePath) {
307
+ console.log(JSON.stringify({ continue: true }));
308
+ return;
309
+ }
310
+
311
+ const result = validatePath(filePath, action);
312
+
313
+ switch (result.action) {
314
+ case 'block':
315
+ logSecurityEvent({
316
+ timestamp: new Date().toISOString(),
317
+ session_id: input.session_id,
318
+ event_type: 'block',
319
+ tool: input.tool_name,
320
+ category: 'path_access',
321
+ target: filePath,
322
+ reason: result.reason,
323
+ action_taken: 'Hard block - exit 2',
324
+ });
325
+ console.error(`[GMTR SECURITY] BLOCKED: ${result.reason}`);
326
+ process.exit(2);
327
+ break;
328
+
329
+ case 'confirm':
330
+ logSecurityEvent({
331
+ timestamp: new Date().toISOString(),
332
+ session_id: input.session_id,
333
+ event_type: 'confirm',
334
+ tool: input.tool_name,
335
+ category: 'path_access',
336
+ target: filePath,
337
+ reason: result.reason,
338
+ action_taken: 'Prompted user for confirmation',
339
+ });
340
+ console.log(JSON.stringify({
341
+ decision: 'ask',
342
+ message: `[GMTR SECURITY] ${result.reason}\n\nPath: ${filePath}\n\nProceed?`,
343
+ }));
344
+ break;
345
+
346
+ default:
347
+ console.log(JSON.stringify({ continue: true }));
348
+ }
349
+ }
350
+
351
+ // ── Main ──
352
+
353
+ async function main(): Promise<void> {
354
+ let input: HookInput;
355
+
356
+ try {
357
+ const chunks: Buffer[] = [];
358
+ await new Promise<void>((resolve) => {
359
+ const timeout = setTimeout(() => { process.stdin.destroy(); resolve(); }, 200);
360
+ process.stdin.on('data', (chunk: Buffer) => chunks.push(chunk));
361
+ process.stdin.on('end', () => { clearTimeout(timeout); resolve(); });
362
+ process.stdin.on('error', () => { clearTimeout(timeout); resolve(); });
363
+ process.stdin.resume();
364
+ });
365
+ const raw = Buffer.concat(chunks).toString('utf8');
366
+
367
+ if (!raw.trim()) {
368
+ console.log(JSON.stringify({ continue: true }));
369
+ return;
370
+ }
371
+
372
+ input = JSON.parse(raw);
373
+ } catch {
374
+ console.log(JSON.stringify({ continue: true }));
375
+ return;
376
+ }
377
+
378
+ switch (input.tool_name) {
379
+ case 'Bash':
380
+ handleBash(input);
381
+ break;
382
+ case 'Edit':
383
+ case 'MultiEdit':
384
+ handlePathTool(input, 'write');
385
+ break;
386
+ case 'Write':
387
+ handlePathTool(input, 'write');
388
+ break;
389
+ case 'Read':
390
+ handlePathTool(input, 'read');
391
+ break;
392
+ default:
393
+ console.log(JSON.stringify({ continue: true }));
394
+ }
395
+ }
396
+
397
+ main().catch(() => {
398
+ console.log(JSON.stringify({ continue: true }));
399
+ });