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.
- package/CLAUDE.md +18 -0
- package/README.md +78 -0
- package/bin/clean-legacy-install.ts +28 -0
- package/bin/get-token.py +3 -0
- package/bin/gmtr-login.ts +547 -0
- package/bin/gramatr.js +33 -0
- package/bin/gramatr.ts +248 -0
- package/bin/install.ts +756 -0
- package/bin/render-claude-hooks.ts +16 -0
- package/bin/statusline.ts +437 -0
- package/bin/uninstall.ts +289 -0
- package/bin/version-sync.ts +46 -0
- package/codex/README.md +28 -0
- package/codex/hooks/session-start.ts +73 -0
- package/codex/hooks/stop.ts +34 -0
- package/codex/hooks/user-prompt-submit.ts +76 -0
- package/codex/install.ts +99 -0
- package/codex/lib/codex-hook-utils.ts +48 -0
- package/codex/lib/codex-install-utils.ts +123 -0
- package/core/feedback.ts +55 -0
- package/core/formatting.ts +167 -0
- package/core/install.ts +114 -0
- package/core/installer-cli.ts +122 -0
- package/core/migration.ts +244 -0
- package/core/routing.ts +98 -0
- package/core/session.ts +202 -0
- package/core/targets.ts +292 -0
- package/core/types.ts +178 -0
- package/core/version.ts +2 -0
- package/gemini/README.md +95 -0
- package/gemini/hooks/session-start.ts +72 -0
- package/gemini/hooks/stop.ts +30 -0
- package/gemini/hooks/user-prompt-submit.ts +74 -0
- package/gemini/install.ts +272 -0
- package/gemini/lib/gemini-hook-utils.ts +63 -0
- package/gemini/lib/gemini-install-utils.ts +169 -0
- package/hooks/GMTRPromptEnricher.hook.ts +650 -0
- package/hooks/GMTRRatingCapture.hook.ts +198 -0
- package/hooks/GMTRSecurityValidator.hook.ts +399 -0
- package/hooks/GMTRToolTracker.hook.ts +181 -0
- package/hooks/StopOrchestrator.hook.ts +78 -0
- package/hooks/gmtr-tool-tracker-utils.ts +105 -0
- package/hooks/lib/gmtr-hook-utils.ts +771 -0
- package/hooks/lib/identity.ts +227 -0
- package/hooks/lib/notify.ts +46 -0
- package/hooks/lib/paths.ts +104 -0
- package/hooks/lib/transcript-parser.ts +452 -0
- package/hooks/session-end.hook.ts +168 -0
- package/hooks/session-start.hook.ts +490 -0
- 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
|
+
});
|