gm-oc 2.0.173 → 2.0.175
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/hooks/pre-tool-use-hook.js +166 -204
- package/package.json +1 -1
|
@@ -1,204 +1,166 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
const os = require('os');
|
|
6
|
-
const { execSync, spawnSync } = require('child_process');
|
|
7
|
-
|
|
8
|
-
const isGemini = process.env.GEMINI_PROJECT_DIR !== undefined;
|
|
9
|
-
|
|
10
|
-
const writeTools = ['Write', 'write_file'];
|
|
11
|
-
const searchTools = ['glob', 'search_file_content', 'Search', 'search'];
|
|
12
|
-
const forbiddenTools = ['find', 'Find', 'Glob', 'Grep'];
|
|
13
|
-
|
|
14
|
-
const run = () => {
|
|
15
|
-
try {
|
|
16
|
-
const input = fs.readFileSync(0, 'utf-8');
|
|
17
|
-
const data = JSON.parse(input);
|
|
18
|
-
const { tool_name, tool_input } = data;
|
|
19
|
-
|
|
20
|
-
if (!tool_name) return { allow: true };
|
|
21
|
-
|
|
22
|
-
if (forbiddenTools.includes(tool_name)) {
|
|
23
|
-
return { block: true, reason: 'Use the code-search skill for codebase exploration instead of Grep/Glob/find. Describe what you need in plain language — it understands intent, not just patterns.' };
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
if (writeTools.includes(tool_name)) {
|
|
27
|
-
const file_path = tool_input?.file_path || '';
|
|
28
|
-
const ext = path.extname(file_path);
|
|
29
|
-
const inSkillsDir = file_path.includes('/skills/') || file_path.includes('\\skills\\');
|
|
30
|
-
const base = path.basename(file_path).toLowerCase();
|
|
31
|
-
if ((ext === '.md' || ext === '.txt' || base.startsWith('features_list')) &&
|
|
32
|
-
!base.startsWith('claude') && !base.startsWith('readme') && !inSkillsDir) {
|
|
33
|
-
return { block: true, reason: 'Cannot create documentation files. Only CLAUDE.md and readme.md are maintained. For task-specific notes, use .prd. For permanent reference material, add to CLAUDE.md.' };
|
|
34
|
-
}
|
|
35
|
-
if (/\.(test|spec)\.(js|ts|jsx|tsx|mjs|cjs)$/.test(base) ||
|
|
36
|
-
/^(jest|vitest|mocha|ava|jasmine|tap)\.(config|setup)/.test(base) ||
|
|
37
|
-
file_path.includes('/__tests__/') || file_path.includes('/test/') ||
|
|
38
|
-
file_path.includes('/tests/') || file_path.includes('/fixtures/') ||
|
|
39
|
-
file_path.includes('/test-data/') || file_path.includes('/__mocks__/') ||
|
|
40
|
-
/\.(snap|stub|mock|fixture)\.(js|ts|json)$/.test(base)) {
|
|
41
|
-
return { block: true, reason: 'Test files forbidden on disk. Use Bash tool with real services for all testing.' };
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
if (searchTools.includes(tool_name)) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const r = spawnSync(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
if (
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
result = spawnDirect('
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
result = spawnDirect('
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
} catch (e) {
|
|
168
|
-
return { block: true, reason: `exec ran. Error:\n\n${(e.stdout || '') + (e.stderr || '') || e.message || '(exec failed)'}` };
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Allow essential tools explicitly
|
|
174
|
-
const allowedTools = ['agent-browser', 'Skill', 'code-search', 'electron', 'TaskOutput', 'ReadMcpResourceTool', 'ListMcpResourcesTool'];
|
|
175
|
-
if (allowedTools.includes(tool_name)) {
|
|
176
|
-
return { allow: true };
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
return { allow: true };
|
|
180
|
-
} catch (error) {
|
|
181
|
-
return { allow: true };
|
|
182
|
-
}
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
try {
|
|
186
|
-
const result = run();
|
|
187
|
-
|
|
188
|
-
if (result.block) {
|
|
189
|
-
if (isGemini) {
|
|
190
|
-
console.log(JSON.stringify({ decision: 'deny', reason: result.reason }));
|
|
191
|
-
} else {
|
|
192
|
-
console.log(JSON.stringify({ decision: 'block', reason: result.reason }));
|
|
193
|
-
}
|
|
194
|
-
process.exit(0);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
if (isGemini) {
|
|
198
|
-
console.log(JSON.stringify({ decision: 'allow' }));
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
process.exit(0);
|
|
202
|
-
} catch (error) {
|
|
203
|
-
process.exit(0);
|
|
204
|
-
}
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const { execSync, spawnSync } = require('child_process');
|
|
7
|
+
|
|
8
|
+
const isGemini = process.env.GEMINI_PROJECT_DIR !== undefined;
|
|
9
|
+
|
|
10
|
+
const writeTools = ['Write', 'write_file'];
|
|
11
|
+
const searchTools = ['glob', 'search_file_content', 'Search', 'search'];
|
|
12
|
+
const forbiddenTools = ['find', 'Find', 'Glob', 'Grep'];
|
|
13
|
+
|
|
14
|
+
const run = () => {
|
|
15
|
+
try {
|
|
16
|
+
const input = fs.readFileSync(0, 'utf-8');
|
|
17
|
+
const data = JSON.parse(input);
|
|
18
|
+
const { tool_name, tool_input } = data;
|
|
19
|
+
|
|
20
|
+
if (!tool_name) return { allow: true };
|
|
21
|
+
|
|
22
|
+
if (forbiddenTools.includes(tool_name)) {
|
|
23
|
+
return { block: true, reason: 'Use the code-search skill for codebase exploration instead of Grep/Glob/find. Describe what you need in plain language — it understands intent, not just patterns.' };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (writeTools.includes(tool_name)) {
|
|
27
|
+
const file_path = tool_input?.file_path || '';
|
|
28
|
+
const ext = path.extname(file_path);
|
|
29
|
+
const inSkillsDir = file_path.includes('/skills/') || file_path.includes('\\skills\\');
|
|
30
|
+
const base = path.basename(file_path).toLowerCase();
|
|
31
|
+
if ((ext === '.md' || ext === '.txt' || base.startsWith('features_list')) &&
|
|
32
|
+
!base.startsWith('claude') && !base.startsWith('readme') && !inSkillsDir) {
|
|
33
|
+
return { block: true, reason: 'Cannot create documentation files. Only CLAUDE.md and readme.md are maintained. For task-specific notes, use .prd. For permanent reference material, add to CLAUDE.md.' };
|
|
34
|
+
}
|
|
35
|
+
if (/\.(test|spec)\.(js|ts|jsx|tsx|mjs|cjs)$/.test(base) ||
|
|
36
|
+
/^(jest|vitest|mocha|ava|jasmine|tap)\.(config|setup)/.test(base) ||
|
|
37
|
+
file_path.includes('/__tests__/') || file_path.includes('/test/') ||
|
|
38
|
+
file_path.includes('/tests/') || file_path.includes('/fixtures/') ||
|
|
39
|
+
file_path.includes('/test-data/') || file_path.includes('/__mocks__/') ||
|
|
40
|
+
/\.(snap|stub|mock|fixture)\.(js|ts|json)$/.test(base)) {
|
|
41
|
+
return { block: true, reason: 'Test files forbidden on disk. Use Bash tool with real services for all testing.' };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (searchTools.includes(tool_name)) return { allow: true };
|
|
46
|
+
|
|
47
|
+
if (tool_name === 'Task' && (tool_input?.subagent_type || '') === 'Explore') {
|
|
48
|
+
return { block: true, reason: 'Use gm:thorns-overview for codebase insight, then use gm:code-search' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (tool_name === 'EnterPlanMode') {
|
|
52
|
+
return { block: true, reason: 'Plan mode is disabled. Use GM agent planning (PLAN→EXECUTE→EMIT→VERIFY→COMPLETE state machine) via gm:gm subagent instead.' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (tool_name === 'Skill') {
|
|
56
|
+
const skill = (tool_input?.skill || '').toLowerCase().replace(/^gm:/, '');
|
|
57
|
+
if (skill === 'explore' || skill === 'search') {
|
|
58
|
+
return { block: true, reason: 'Use the code-search skill for codebase exploration. Describe what you need in plain language — it understands intent, not just patterns.' };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (tool_name === 'Bash') {
|
|
63
|
+
const command = (tool_input?.command || '').trim();
|
|
64
|
+
const execMatch = command.match(/^exec(?::(\S+))?\n([\s\S]+)$/);
|
|
65
|
+
if (execMatch) {
|
|
66
|
+
const rawLang = (execMatch[1] || '').toLowerCase();
|
|
67
|
+
const code = execMatch[2];
|
|
68
|
+
const cwd = tool_input?.cwd;
|
|
69
|
+
const detectLang = (src) => {
|
|
70
|
+
if (/^\s*(import |from |export |const |let |var |function |class |async |await |console\.|process\.)/.test(src)) return 'nodejs';
|
|
71
|
+
if (/^\s*(import |def |print\(|class |if __name__)/.test(src)) return 'python';
|
|
72
|
+
if (/^\s*(echo |ls |cd |mkdir |rm |cat |grep |find |export |source |#!)/.test(src)) return 'bash';
|
|
73
|
+
return 'nodejs';
|
|
74
|
+
};
|
|
75
|
+
const aliases = { js: 'nodejs', javascript: 'nodejs', ts: 'typescript', node: 'nodejs', py: 'python', sh: 'bash', shell: 'bash', zsh: 'bash', powershell: 'bash', ps1: 'bash', cmd: 'bash', browser: 'agent-browser', ab: 'agent-browser' };
|
|
76
|
+
const lang = aliases[rawLang] || rawLang || detectLang(code);
|
|
77
|
+
const IS_WIN = process.platform === 'win32';
|
|
78
|
+
const stripFooter = (s) => s.replace(/\n\[Running tools\][\s\S]*$/, '').trimEnd();
|
|
79
|
+
const langExts = { nodejs: 'mjs', typescript: 'ts', deno: 'ts', python: 'py', bash: IS_WIN ? 'ps1' : 'sh', go: 'go', rust: 'rs', c: 'c', cpp: 'cpp', java: 'java' };
|
|
80
|
+
const spawnDirect = (bin, args, stdin) => {
|
|
81
|
+
const opts = { encoding: 'utf-8', timeout: 60000, ...(cwd && { cwd }), ...(stdin !== undefined && { input: stdin }) };
|
|
82
|
+
const r = spawnSync(bin, args, opts);
|
|
83
|
+
if (!r.stdout && !r.stderr && r.error) return `[spawn error: ${r.error.message}]`;
|
|
84
|
+
const out = (r.stdout || '').trimEnd(), err = stripFooter(r.stderr || '').trimEnd();
|
|
85
|
+
return out && err ? out + '\n[stderr]\n' + err : stripFooter(out || err);
|
|
86
|
+
};
|
|
87
|
+
const runWithFile = (l, src) => {
|
|
88
|
+
const tmp = path.join(os.tmpdir(), `gm-exec-${Date.now()}.${langExts[l] || l}`);
|
|
89
|
+
fs.writeFileSync(tmp, src, 'utf-8');
|
|
90
|
+
const r = spawnSync('bun', ['x', 'gm-exec', 'exec', `--lang=${l}`, `--file=${tmp}`, ...(cwd ? [`--cwd=${cwd}`] : [])], { encoding: 'utf-8', timeout: 65000 });
|
|
91
|
+
try { fs.unlinkSync(tmp); } catch (e) {}
|
|
92
|
+
let out = stripFooter((r.stdout || '') + (r.stderr || ''));
|
|
93
|
+
const bg = out.match(/Command running in background with ID:\s*(\S+)/);
|
|
94
|
+
if (bg) {
|
|
95
|
+
spawnSync('bun', ['x', 'gm-exec', 'sleep', bg[1], '60'], { encoding: 'utf-8', timeout: 70000 });
|
|
96
|
+
const sr = spawnSync('bun', ['x', 'gm-exec', 'status', bg[1]], { encoding: 'utf-8', timeout: 15000 });
|
|
97
|
+
out = stripFooter((sr.stdout || '') + (sr.stderr || ''));
|
|
98
|
+
spawnSync('bun', ['x', 'gm-exec', 'close', bg[1]], { encoding: 'utf-8', timeout: 10000 });
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
};
|
|
102
|
+
const decodeB64 = (s) => {
|
|
103
|
+
const t = s.trim();
|
|
104
|
+
if (t.length < 16 || t.length % 4 !== 0 || !/^[A-Za-z0-9+/\r\n]+=*$/.test(t)) return s;
|
|
105
|
+
try { const d = Buffer.from(t, 'base64').toString('utf-8'); return /[\x00-\x08\x0b\x0e-\x1f]/.test(d) ? s : d; } catch { return s; }
|
|
106
|
+
};
|
|
107
|
+
const safeCode = decodeB64(code);
|
|
108
|
+
try {
|
|
109
|
+
let result;
|
|
110
|
+
if (lang === 'bash') {
|
|
111
|
+
const shFile = path.join(os.tmpdir(), `gm-exec-${Date.now()}.${IS_WIN ? 'ps1' : 'sh'}`);
|
|
112
|
+
fs.writeFileSync(shFile, safeCode, 'utf-8');
|
|
113
|
+
result = IS_WIN
|
|
114
|
+
? spawnDirect('powershell', ['-NoProfile', '-NonInteractive', '-File', shFile])
|
|
115
|
+
: spawnDirect('bash', [shFile]);
|
|
116
|
+
try { fs.unlinkSync(shFile); } catch (e) {}
|
|
117
|
+
if (!result || result.startsWith('[spawn error:')) result = runWithFile('bash', safeCode);
|
|
118
|
+
} else if (lang === 'python') {
|
|
119
|
+
result = spawnDirect('python3', ['-c', safeCode]);
|
|
120
|
+
if (!result || result.startsWith('[spawn error:')) result = spawnDirect('python', ['-c', safeCode]);
|
|
121
|
+
} else if (!lang || ['nodejs', 'typescript', 'deno'].includes(lang)) {
|
|
122
|
+
const ext = lang === 'typescript' || lang === 'deno' ? 'ts' : 'mjs';
|
|
123
|
+
const tmp = path.join(os.tmpdir(), `gm-exec-${Date.now()}.${ext}`);
|
|
124
|
+
const wrapped = `const __result = await (async () => {\n${safeCode}\n})();\nif (__result !== undefined) { if (typeof __result === 'object') { console.log(JSON.stringify(__result, null, 2)); } else { console.log(__result); } }`;
|
|
125
|
+
fs.writeFileSync(tmp, wrapped, 'utf-8');
|
|
126
|
+
result = spawnDirect('bun', ['run', tmp]);
|
|
127
|
+
try { fs.unlinkSync(tmp); } catch (e) {}
|
|
128
|
+
if (result) result = result.split(tmp).join('<script>');
|
|
129
|
+
} else if (lang === 'agent-browser') {
|
|
130
|
+
result = spawnDirect('agent-browser', ['eval', '--stdin'], safeCode);
|
|
131
|
+
} else {
|
|
132
|
+
result = runWithFile(lang, safeCode);
|
|
133
|
+
}
|
|
134
|
+
return { block: true, reason: `exec ran successfully. Output:\n\n${result || '(no output)'}` };
|
|
135
|
+
} catch (e) {
|
|
136
|
+
return { block: true, reason: `exec ran. Error:\n\n${(e.stdout || '') + (e.stderr || '') || e.message || '(exec failed)'}` };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!/^exec(\s|:)/.test(command) && !/^bun x gm-exec(@[^\s]*)?(\s|$)/.test(command) && !/^git /.test(command) && !/^bun x codebasesearch/.test(command) && !/(\bclaude\b)/.test(command) && !/^npm install .* \/config\/.gmweb/.test(command) && !/^bun install --cwd \/config\/.gmweb/.test(command)) {
|
|
141
|
+
let helpText = '';
|
|
142
|
+
try { helpText = '\n\n' + execSync('bun x gm-exec --help', { timeout: 10000 }).toString().trim(); } catch (e) {}
|
|
143
|
+
return { block: true, reason: `Bash is restricted to exec:<lang> and git.\n\nexec:<lang> syntax (lang auto-detected if omitted):\n exec:nodejs / exec:python / exec:bash / exec:typescript\n exec:go / exec:rust / exec:java / exec:c / exec:cpp\n exec:agent-browser ← plain JS piped to browser eval (NO base64)\n exec ← auto-detects language\n\nNEVER encode agent-browser code as base64 — pass plain JS directly.\n\nbun x gm-exec${helpText}\n\nAll other Bash commands are blocked.` };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const allowedTools = ['agent-browser', 'Skill', 'code-search', 'electron', 'TaskOutput', 'ReadMcpResourceTool', 'ListMcpResourcesTool'];
|
|
148
|
+
if (allowedTools.includes(tool_name)) return { allow: true };
|
|
149
|
+
|
|
150
|
+
return { allow: true };
|
|
151
|
+
} catch (error) {
|
|
152
|
+
return { allow: true };
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const result = run();
|
|
158
|
+
if (result.block) {
|
|
159
|
+
console.log(JSON.stringify({ decision: isGemini ? 'deny' : 'block', reason: result.reason }));
|
|
160
|
+
process.exit(0);
|
|
161
|
+
}
|
|
162
|
+
if (isGemini) console.log(JSON.stringify({ decision: 'allow' }));
|
|
163
|
+
process.exit(0);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
process.exit(0);
|
|
166
|
+
}
|