teleportation-cli 1.4.4 → 1.4.5
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/.gemini/hooks/after_agent.mjs +190 -0
- package/.gemini/hooks/after_agent.test.mjs +121 -0
- package/.gemini/hooks/after_tool.mjs +126 -0
- package/.gemini/hooks/before_tool.mjs +276 -0
- package/.gemini/hooks/session_end.mjs +158 -0
- package/.gemini/hooks/session_start.mjs +193 -0
- package/.gemini/hooks/shared/config.mjs +67 -0
- package/.gemini/hooks/test-hooks.mjs +254 -0
- package/package.json +4 -1
- package/teleportation-cli.cjs +5 -0
- package/teleportation.uhr.json +76 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Gemini CLI AfterAgent Hook
|
|
4
|
+
*
|
|
5
|
+
* This hook fires when the Gemini agent completes its planning/execution loop.
|
|
6
|
+
* It mirrors the Claude Code stop hook for Teleportation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { stdin, stdout, exit, env } from 'node:process';
|
|
10
|
+
import { readFile } from 'node:fs/promises';
|
|
11
|
+
import { appendFileSync, existsSync } from 'node:fs';
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { join, dirname } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { loadConfig } from './shared/config.mjs';
|
|
17
|
+
|
|
18
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
19
|
+
const __dirname = dirname(__filename);
|
|
20
|
+
|
|
21
|
+
// Security: Max input size to prevent memory exhaustion
|
|
22
|
+
const MAX_INPUT_SIZE = 10 * 1024 * 1024; // 10MB
|
|
23
|
+
|
|
24
|
+
// Read all stdin
|
|
25
|
+
const readStdin = () => new Promise((resolve, reject) => {
|
|
26
|
+
let data = '';
|
|
27
|
+
stdin.setEncoding('utf8');
|
|
28
|
+
stdin.on('data', chunk => {
|
|
29
|
+
data += chunk;
|
|
30
|
+
if (data.length > MAX_INPUT_SIZE) {
|
|
31
|
+
reject(new Error('Input too large'));
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
stdin.on('end', () => resolve(data));
|
|
35
|
+
stdin.on('error', reject);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Logging
|
|
39
|
+
const hookLogFile = env.TELEPORTATION_HOOK_LOG || '/tmp/teleportation-gemini-hook.log';
|
|
40
|
+
const log = (msg) => {
|
|
41
|
+
const timestamp = new Date().toISOString();
|
|
42
|
+
const logMsg = `[${timestamp}] [gemini:after_agent] ${msg}\n`;
|
|
43
|
+
try {
|
|
44
|
+
appendFileSync(hookLogFile, logMsg);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
// Silently ignore
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Generate MD5 hash of project slug for Gemini's directory naming.
|
|
52
|
+
*/
|
|
53
|
+
function hashSlug(slug) {
|
|
54
|
+
if (!slug) return createHash('md5').update('default').digest('hex').substring(0, 16);
|
|
55
|
+
return createHash('md5').update(slug).digest('hex').substring(0, 16);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Find and parse the Gemini transcript
|
|
60
|
+
*/
|
|
61
|
+
async function getTranscript(cwd, log) {
|
|
62
|
+
const hash = hashSlug(cwd);
|
|
63
|
+
const transcriptPath = join(homedir(), '.gemini', 'tmp', hash, 'logs.json');
|
|
64
|
+
|
|
65
|
+
if (!existsSync(transcriptPath)) {
|
|
66
|
+
log(`Transcript not found at ${transcriptPath}`);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const content = await readFile(transcriptPath, 'utf8');
|
|
72
|
+
return JSON.parse(content);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
log(`Failed to parse transcript: ${e.message}`);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
(async () => {
|
|
80
|
+
log('=== AfterAgent hook invoked ===');
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const raw = await readStdin();
|
|
84
|
+
let input;
|
|
85
|
+
try {
|
|
86
|
+
input = JSON.parse(raw || '{}');
|
|
87
|
+
} catch (e) {
|
|
88
|
+
log(`Invalid JSON input: ${e.message}`);
|
|
89
|
+
return exit(0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const { session_id, model, response, cwd } = input;
|
|
93
|
+
const workingDir = cwd || process.cwd();
|
|
94
|
+
const teleportSessionId = env.TELEPORTATION_SESSION_ID || session_id || 'unknown';
|
|
95
|
+
|
|
96
|
+
log(`Session: ${teleportSessionId}, Model: ${model}, CWD: ${workingDir}`);
|
|
97
|
+
|
|
98
|
+
// Load config from shared module
|
|
99
|
+
const config = await loadConfig(log);
|
|
100
|
+
const RELAY_API_URL = config.relayApiUrl;
|
|
101
|
+
const RELAY_API_KEY = config.relayApiKey;
|
|
102
|
+
|
|
103
|
+
log(`Using Relay: ${RELAY_API_URL}`);
|
|
104
|
+
|
|
105
|
+
if (!RELAY_API_URL || !RELAY_API_KEY) {
|
|
106
|
+
log('No relay configured, skipping transcript ingestion');
|
|
107
|
+
return exit(0);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let assistantText = '';
|
|
111
|
+
|
|
112
|
+
// 1. Try to use response from input if provided
|
|
113
|
+
if (response && response.content) {
|
|
114
|
+
log('Using response from hook input');
|
|
115
|
+
assistantText = typeof response.content === 'string'
|
|
116
|
+
? response.content
|
|
117
|
+
: JSON.stringify(response.content);
|
|
118
|
+
}
|
|
119
|
+
// 2. Otherwise, parse the transcript file
|
|
120
|
+
else {
|
|
121
|
+
log('Parsing transcript file from disk');
|
|
122
|
+
const transcript = await getTranscript(workingDir, log);
|
|
123
|
+
if (transcript && Array.isArray(transcript)) {
|
|
124
|
+
// Find last model message
|
|
125
|
+
for (let i = transcript.length - 1; i >= 0; i--) {
|
|
126
|
+
const msg = transcript[i];
|
|
127
|
+
if (msg.role === 'model' || msg.role === 'assistant') {
|
|
128
|
+
if (Array.isArray(msg.parts)) {
|
|
129
|
+
assistantText = msg.parts
|
|
130
|
+
.filter(p => p.text)
|
|
131
|
+
.map(p => p.text)
|
|
132
|
+
.join('\n\n');
|
|
133
|
+
} else if (typeof msg.content === 'string') {
|
|
134
|
+
assistantText = msg.content;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (assistantText) break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!assistantText) {
|
|
144
|
+
log('No assistant message found to log');
|
|
145
|
+
return exit(0);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Truncate if too long (parity with Claude)
|
|
149
|
+
const ASSISTANT_RESPONSE_MAX_LENGTH = 2000;
|
|
150
|
+
const preview = assistantText.length > ASSISTANT_RESPONSE_MAX_LENGTH
|
|
151
|
+
? assistantText.slice(0, ASSISTANT_RESPONSE_MAX_LENGTH) + '...'
|
|
152
|
+
: assistantText;
|
|
153
|
+
|
|
154
|
+
// Log to timeline
|
|
155
|
+
const timelineEvent = {
|
|
156
|
+
session_id: teleportSessionId,
|
|
157
|
+
type: 'assistant_response',
|
|
158
|
+
data: {
|
|
159
|
+
message: preview,
|
|
160
|
+
model: model || null,
|
|
161
|
+
full_length: assistantText.length,
|
|
162
|
+
truncated: assistantText.length > ASSISTANT_RESPONSE_MAX_LENGTH,
|
|
163
|
+
timestamp: Date.now()
|
|
164
|
+
},
|
|
165
|
+
source: 'gemini-cli',
|
|
166
|
+
timestamp: Date.now()
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
await fetch(`${RELAY_API_URL}/api/timeline`, {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: {
|
|
173
|
+
'Content-Type': 'application/json',
|
|
174
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`
|
|
175
|
+
},
|
|
176
|
+
body: JSON.stringify(timelineEvent),
|
|
177
|
+
signal: AbortSignal.timeout(5000)
|
|
178
|
+
});
|
|
179
|
+
log(`Logged assistant_response (${assistantText.length} chars)`);
|
|
180
|
+
} catch (e) {
|
|
181
|
+
log(`Failed to log timeline event: ${e.message}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return exit(0);
|
|
185
|
+
|
|
186
|
+
} catch (e) {
|
|
187
|
+
log(`Hook error: ${e.message}`);
|
|
188
|
+
return exit(0);
|
|
189
|
+
}
|
|
190
|
+
})();
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Tests for Gemini CLI AfterAgent hook
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'bun:test';
|
|
7
|
+
import * as fs from 'node:fs';
|
|
8
|
+
import * as fsPromises from 'node:fs/promises';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
|
|
12
|
+
// Helper to simulate running the hook script
|
|
13
|
+
// We'll extract the core logic into a testable function or just mock the dependencies
|
|
14
|
+
// and require the script. Since it's an IIFE, we might need to refactor it slightly
|
|
15
|
+
// for better testability, but for now we'll mock the global environment.
|
|
16
|
+
|
|
17
|
+
describe('AfterAgent Hook Logic', () => {
|
|
18
|
+
let originalFetch = globalThis.fetch;
|
|
19
|
+
let existsSyncSpy;
|
|
20
|
+
let readFileSpy;
|
|
21
|
+
let fetchSpy;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
existsSyncSpy = vi.spyOn(fs, 'existsSync');
|
|
25
|
+
readFileSpy = vi.spyOn(fsPromises, 'readFile');
|
|
26
|
+
fetchSpy = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ success: true }) });
|
|
27
|
+
globalThis.fetch = fetchSpy;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
vi.restoreAllMocks();
|
|
32
|
+
globalThis.fetch = originalFetch;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should calculate the correct transcript path', () => {
|
|
36
|
+
const { createHash } = require('node:crypto');
|
|
37
|
+
const cwd = '/Users/test/project';
|
|
38
|
+
const hash = createHash('md5').update(cwd).digest('hex').substring(0, 16);
|
|
39
|
+
const expectedPath = join(homedir(), '.gemini', 'tmp', hash, 'logs.json');
|
|
40
|
+
|
|
41
|
+
// This just verifies our understanding of the path calculation
|
|
42
|
+
expect(expectedPath).toContain('.gemini/tmp/');
|
|
43
|
+
expect(expectedPath).toContain('/logs.json');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Since we can't easily run the IIFE in the script without refactoring,
|
|
47
|
+
// let's at least test the parsing logic by re-implementing it here
|
|
48
|
+
// and ensuring it works as expected. This verifies the "Dialectical Autocoder"
|
|
49
|
+
// requirement for robust parsing.
|
|
50
|
+
|
|
51
|
+
const parseTranscript = (content) => {
|
|
52
|
+
try {
|
|
53
|
+
const transcript = JSON.parse(content);
|
|
54
|
+
if (!Array.isArray(transcript)) return null;
|
|
55
|
+
|
|
56
|
+
for (let i = transcript.length - 1; i >= 0; i--) {
|
|
57
|
+
const msg = transcript[i];
|
|
58
|
+
if (msg.role === 'model' || msg.role === 'assistant') {
|
|
59
|
+
if (Array.isArray(msg.parts)) {
|
|
60
|
+
return msg.parts
|
|
61
|
+
.filter(p => p.text)
|
|
62
|
+
.map(p => p.text)
|
|
63
|
+
.join('\n\n');
|
|
64
|
+
} else if (typeof msg.content === 'string') {
|
|
65
|
+
return msg.content;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
it('should parse assistant response from JSON array transcript', () => {
|
|
76
|
+
const mockTranscript = JSON.stringify([
|
|
77
|
+
{ role: 'user', parts: [{ text: 'hello' }] },
|
|
78
|
+
{ role: 'model', parts: [{ text: 'hi there' }] }
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
const result = parseTranscript(mockTranscript);
|
|
82
|
+
expect(result).toBe('hi there');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should parse assistant response with multiple parts', () => {
|
|
86
|
+
const mockTranscript = JSON.stringify([
|
|
87
|
+
{ role: 'model', parts: [{ text: 'Part 1' }, { text: 'Part 2' }] }
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
const result = parseTranscript(mockTranscript);
|
|
91
|
+
expect(result).toBe('Part 1\n\nPart 2');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should parse assistant response from content field (alternative format)', () => {
|
|
95
|
+
const mockTranscript = JSON.stringify([
|
|
96
|
+
{ role: 'assistant', content: 'Legacy format response' }
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
const result = parseTranscript(mockTranscript);
|
|
100
|
+
expect(result).toBe('Legacy format response');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should return null for transcript without assistant messages', () => {
|
|
104
|
+
const mockTranscript = JSON.stringify([
|
|
105
|
+
{ role: 'user', parts: [{ text: 'hello' }] }
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
const result = parseTranscript(mockTranscript);
|
|
109
|
+
expect(result).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should handle malformed JSON gracefully', () => {
|
|
113
|
+
const result = parseTranscript('not json');
|
|
114
|
+
expect(result).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should handle empty transcript', () => {
|
|
118
|
+
const result = parseTranscript('[]');
|
|
119
|
+
expect(result).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Gemini CLI AfterTool Hook
|
|
4
|
+
*
|
|
5
|
+
* This hook executes AFTER any tool runs in Gemini CLI.
|
|
6
|
+
* It mirrors the Claude Code post_tool_use hook for Teleportation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { stdin, stdout, exit, env } from 'node:process';
|
|
10
|
+
import { appendFileSync } from 'node:fs';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
import { dirname } from 'path';
|
|
13
|
+
import { loadConfig } from './shared/config.mjs';
|
|
14
|
+
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
16
|
+
const __dirname = dirname(__filename);
|
|
17
|
+
|
|
18
|
+
// Security: Max input size to prevent memory exhaustion
|
|
19
|
+
const MAX_INPUT_SIZE = 10 * 1024 * 1024; // 10MB
|
|
20
|
+
|
|
21
|
+
// Read all stdin
|
|
22
|
+
const readStdin = () => new Promise((resolve, reject) => {
|
|
23
|
+
let data = '';
|
|
24
|
+
stdin.setEncoding('utf8');
|
|
25
|
+
stdin.on('data', chunk => {
|
|
26
|
+
data += chunk;
|
|
27
|
+
if (data.length > MAX_INPUT_SIZE) {
|
|
28
|
+
reject(new Error('Input too large'));
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
stdin.on('end', () => resolve(data));
|
|
32
|
+
stdin.on('error', reject);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Logging
|
|
36
|
+
const hookLogFile = env.TELEPORTATION_HOOK_LOG || '/tmp/teleportation-gemini-hook.log';
|
|
37
|
+
const log = (msg) => {
|
|
38
|
+
const timestamp = new Date().toISOString();
|
|
39
|
+
const logMsg = `[${timestamp}] [gemini:after_tool] ${msg}\n`;
|
|
40
|
+
try {
|
|
41
|
+
appendFileSync(hookLogFile, logMsg);
|
|
42
|
+
} catch (e) {
|
|
43
|
+
// Silently ignore
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Fetch JSON helper
|
|
48
|
+
const fetchJson = async (url, opts) => {
|
|
49
|
+
const res = await fetch(url, opts);
|
|
50
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
51
|
+
return res.json();
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
(async () => {
|
|
55
|
+
log('=== AfterTool hook invoked ===');
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const raw = await readStdin();
|
|
59
|
+
let input;
|
|
60
|
+
try {
|
|
61
|
+
input = JSON.parse(raw || '{}');
|
|
62
|
+
} catch (e) {
|
|
63
|
+
log(`Invalid JSON input: ${e.message}`);
|
|
64
|
+
return exit(0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const { tool_name, tool_input, tool_output, success, error, session_id, duration_ms } = input;
|
|
68
|
+
const teleportSessionId = env.TELEPORTATION_SESSION_ID || session_id || 'unknown';
|
|
69
|
+
log(`Tool: ${tool_name}, Success: ${success}, Duration: ${duration_ms}ms, TeleportSession: ${teleportSessionId}`);
|
|
70
|
+
|
|
71
|
+
// Load config from shared module
|
|
72
|
+
const config = await loadConfig(log);
|
|
73
|
+
const RELAY_API_URL = config.relayApiUrl;
|
|
74
|
+
const RELAY_API_KEY = config.relayApiKey;
|
|
75
|
+
|
|
76
|
+
log(`Using Relay: ${RELAY_API_URL}`);
|
|
77
|
+
|
|
78
|
+
// If no relay configured, just log locally
|
|
79
|
+
if (!RELAY_API_URL || !RELAY_API_KEY) {
|
|
80
|
+
log('No relay configured, skipping timeline event');
|
|
81
|
+
return exit(0);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Log to timeline
|
|
85
|
+
const timelineEvent = {
|
|
86
|
+
session_id: teleportSessionId,
|
|
87
|
+
type: 'tool_executed',
|
|
88
|
+
data: {
|
|
89
|
+
tool_name,
|
|
90
|
+
tool_input: JSON.stringify(tool_input).slice(0, 1000),
|
|
91
|
+
tool_output: typeof tool_output === 'string'
|
|
92
|
+
? tool_output.slice(0, 5000)
|
|
93
|
+
: JSON.stringify(tool_output).slice(0, 5000),
|
|
94
|
+
success,
|
|
95
|
+
error: error || null,
|
|
96
|
+
duration_ms,
|
|
97
|
+
timestamp: Date.now()
|
|
98
|
+
},
|
|
99
|
+
source: 'gemini-cli',
|
|
100
|
+
timestamp: Date.now(),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
await fetch(`${RELAY_API_URL}/api/timeline`, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: {
|
|
107
|
+
'Content-Type': 'application/json',
|
|
108
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`,
|
|
109
|
+
},
|
|
110
|
+
body: JSON.stringify(timelineEvent),
|
|
111
|
+
signal: AbortSignal.timeout(5000),
|
|
112
|
+
});
|
|
113
|
+
log(`Timeline event logged for ${tool_name}`);
|
|
114
|
+
} catch (e) {
|
|
115
|
+
log(`Failed to log timeline event: ${e.message}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Output empty response (hook completed successfully)
|
|
119
|
+
stdout.write('{}');
|
|
120
|
+
return exit(0);
|
|
121
|
+
|
|
122
|
+
} catch (e) {
|
|
123
|
+
log(`Hook error: ${e.message}`);
|
|
124
|
+
return exit(0);
|
|
125
|
+
}
|
|
126
|
+
})();
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Gemini CLI BeforeTool Hook
|
|
4
|
+
*
|
|
5
|
+
* This hook executes BEFORE any tool runs in Gemini CLI.
|
|
6
|
+
* It mirrors the Claude Code pre_tool_use hook for Teleportation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { stdin, stdout, exit, env } from 'node:process';
|
|
10
|
+
import { appendFileSync } from 'node:fs';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
import { dirname } from 'path';
|
|
13
|
+
import { loadConfig } from './shared/config.mjs';
|
|
14
|
+
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
16
|
+
const __dirname = dirname(__filename);
|
|
17
|
+
|
|
18
|
+
// Security: Max input size to prevent memory exhaustion
|
|
19
|
+
const MAX_INPUT_SIZE = 10 * 1024 * 1024; // 10MB
|
|
20
|
+
|
|
21
|
+
// Read all stdin
|
|
22
|
+
const readStdin = () => new Promise((resolve, reject) => {
|
|
23
|
+
let data = '';
|
|
24
|
+
stdin.setEncoding('utf8');
|
|
25
|
+
stdin.on('data', chunk => {
|
|
26
|
+
data += chunk;
|
|
27
|
+
if (data.length > MAX_INPUT_SIZE) {
|
|
28
|
+
reject(new Error('Input too large'));
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
stdin.on('end', () => resolve(data));
|
|
32
|
+
stdin.on('error', reject);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Sleep helper
|
|
36
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
37
|
+
|
|
38
|
+
// Logging
|
|
39
|
+
const hookLogFile = env.TELEPORTATION_HOOK_LOG || '/tmp/teleportation-gemini-hook.log';
|
|
40
|
+
const log = (msg) => {
|
|
41
|
+
const timestamp = new Date().toISOString();
|
|
42
|
+
const logMsg = `[${timestamp}] [gemini:before_tool] ${msg}\n`;
|
|
43
|
+
try {
|
|
44
|
+
appendFileSync(hookLogFile, logMsg);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
// Silently ignore
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Fetch JSON helper
|
|
51
|
+
const fetchJson = async (url, opts) => {
|
|
52
|
+
const res = await fetch(url, opts);
|
|
53
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
54
|
+
return res.json();
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Safe tools that can be auto-approved (read-only operations)
|
|
58
|
+
const SAFE_TOOLS = new Set([
|
|
59
|
+
'read_file',
|
|
60
|
+
'list_directory',
|
|
61
|
+
'search_files',
|
|
62
|
+
'grep_search',
|
|
63
|
+
'file_search',
|
|
64
|
+
'web_search',
|
|
65
|
+
'web_fetch',
|
|
66
|
+
'get_file_info',
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
// Safe shell commands (read-only)
|
|
70
|
+
const SAFE_SHELL_COMMANDS = [
|
|
71
|
+
/^git\s+(status|log|diff|show|branch|remote|config\s+--get)/,
|
|
72
|
+
/^ls(\s|$)/,
|
|
73
|
+
/^pwd(\s|$)/,
|
|
74
|
+
/^cat\s/,
|
|
75
|
+
/^head\s/,
|
|
76
|
+
/^tail\s/,
|
|
77
|
+
/^wc\s/,
|
|
78
|
+
/^echo\s/,
|
|
79
|
+
/^which\s/,
|
|
80
|
+
/^type\s/,
|
|
81
|
+
/^file\s/,
|
|
82
|
+
/^stat\s/,
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
function isSafeTool(toolName, toolInput) {
|
|
86
|
+
// Check if tool is in safe list
|
|
87
|
+
if (SAFE_TOOLS.has(toolName.toLowerCase())) {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Check shell commands
|
|
92
|
+
if (toolName === 'run_shell_command' || toolName === 'bash' || toolName === 'shell') {
|
|
93
|
+
const command = toolInput?.command || toolInput?.cmd || '';
|
|
94
|
+
|
|
95
|
+
// Security: Check for command chaining/injection patterns
|
|
96
|
+
if (/[;&|`$()]/.test(command)) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Security: Check for redirects that could overwrite files
|
|
101
|
+
if (/[><]/.test(command)) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Only allow if matches a safe pattern
|
|
106
|
+
for (const pattern of SAFE_SHELL_COMMANDS) {
|
|
107
|
+
if (pattern.test(command)) {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Output decision to Gemini CLI
|
|
117
|
+
function outputDecision(action, reason = '') {
|
|
118
|
+
const decision = { action, reason };
|
|
119
|
+
stdout.write(JSON.stringify(decision));
|
|
120
|
+
log(`Decision: ${action} - ${reason}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
(async () => {
|
|
124
|
+
log('=== BeforeTool hook invoked ===');
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const raw = await readStdin();
|
|
128
|
+
let input;
|
|
129
|
+
try {
|
|
130
|
+
input = JSON.parse(raw || '{}');
|
|
131
|
+
} catch (e) {
|
|
132
|
+
log(`Invalid JSON input: ${e.message}`);
|
|
133
|
+
outputDecision('ALLOW', 'Invalid input, allowing by default');
|
|
134
|
+
return exit(0);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const { tool_name, tool_input, session_id } = input;
|
|
138
|
+
const teleportSessionId = env.TELEPORTATION_SESSION_ID || session_id || 'unknown';
|
|
139
|
+
log(`Tool: ${tool_name}, Session (input): ${session_id}, TeleportSession: ${teleportSessionId}`);
|
|
140
|
+
|
|
141
|
+
// Load config from shared module
|
|
142
|
+
const config = await loadConfig(log);
|
|
143
|
+
const RELAY_API_URL = config.relayApiUrl;
|
|
144
|
+
const RELAY_API_KEY = config.relayApiKey;
|
|
145
|
+
|
|
146
|
+
log(`Using Relay: ${RELAY_API_URL}`);
|
|
147
|
+
|
|
148
|
+
// If no relay configured, allow all (local-only mode)
|
|
149
|
+
if (!RELAY_API_URL || !RELAY_API_KEY) {
|
|
150
|
+
log('No relay configured, allowing tool');
|
|
151
|
+
outputDecision('ALLOW', 'No relay configured');
|
|
152
|
+
return exit(0);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Check if tool is safe (auto-approve)
|
|
156
|
+
if (isSafeTool(tool_name, tool_input)) {
|
|
157
|
+
log(`Safe tool ${tool_name}, auto-approving`);
|
|
158
|
+
outputDecision('ALLOW', 'Safe tool auto-approved');
|
|
159
|
+
return exit(0);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Check if user is away (should use remote approval)
|
|
163
|
+
let isAway = false;
|
|
164
|
+
try {
|
|
165
|
+
const stateUrl = `${RELAY_API_URL}/api/sessions/${teleportSessionId}/daemon-state`;
|
|
166
|
+
log(`Checking away status at: ${stateUrl}`);
|
|
167
|
+
|
|
168
|
+
const response = await fetch(stateUrl, {
|
|
169
|
+
headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` },
|
|
170
|
+
signal: AbortSignal.timeout(5000),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (response.ok) {
|
|
174
|
+
const state = await response.json();
|
|
175
|
+
isAway = !!state.is_away;
|
|
176
|
+
log(`Session away status: ${isAway}`);
|
|
177
|
+
} else {
|
|
178
|
+
log(`Away check failed: HTTP ${response.status}`);
|
|
179
|
+
}
|
|
180
|
+
} catch (e) {
|
|
181
|
+
log(`Could not check away status: ${e.message}`);
|
|
182
|
+
// If we can't check, assume user is present
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// If user is present, ask them locally
|
|
186
|
+
if (!isAway) {
|
|
187
|
+
log('User is present, asking locally');
|
|
188
|
+
outputDecision('ASK_USER', 'User is present');
|
|
189
|
+
return exit(0);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// User is away - create remote approval request
|
|
193
|
+
log('User is away, creating remote approval request');
|
|
194
|
+
|
|
195
|
+
const approvalPayload = {
|
|
196
|
+
session_id: teleportSessionId,
|
|
197
|
+
tool_name,
|
|
198
|
+
tool_input,
|
|
199
|
+
status: 'pending',
|
|
200
|
+
source: 'gemini-cli',
|
|
201
|
+
meta: {
|
|
202
|
+
cwd: process.cwd(),
|
|
203
|
+
timestamp: Date.now(),
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
let approvalId;
|
|
208
|
+
try {
|
|
209
|
+
const approval = await fetchJson(`${RELAY_API_URL}/api/approvals`, {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: {
|
|
212
|
+
'Content-Type': 'application/json',
|
|
213
|
+
'Authorization': `Bearer ${RELAY_API_KEY}`,
|
|
214
|
+
},
|
|
215
|
+
body: JSON.stringify(approvalPayload),
|
|
216
|
+
signal: AbortSignal.timeout(10000),
|
|
217
|
+
});
|
|
218
|
+
approvalId = approval.id;
|
|
219
|
+
log(`Created approval request: ${approvalId}`);
|
|
220
|
+
} catch (e) {
|
|
221
|
+
log(`Failed to create approval: ${e.message}`);
|
|
222
|
+
// If relay is down, allow (fail-safe for autonomous operation)
|
|
223
|
+
outputDecision('ALLOW', 'Relay unavailable, auto-approved');
|
|
224
|
+
return exit(0);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Poll for approval decision
|
|
228
|
+
// Handle environment variable with proper parsing and bounds
|
|
229
|
+
const POLL_TIMEOUT_MS = Math.max(5000, Math.min(3600000,
|
|
230
|
+
parseInt(env.GEMINI_APPROVAL_TIMEOUT_MS || '60000', 10) || 60000
|
|
231
|
+
));
|
|
232
|
+
const POLL_INTERVAL_MS = Math.max(100, Math.min(30000,
|
|
233
|
+
parseInt(env.GEMINI_POLL_INTERVAL_MS || '2000', 10) || 2000
|
|
234
|
+
));
|
|
235
|
+
|
|
236
|
+
log(`Polling for approval: timeout=${POLL_TIMEOUT_MS}ms, interval=${POLL_INTERVAL_MS}ms`);
|
|
237
|
+
|
|
238
|
+
const startTime = Date.now();
|
|
239
|
+
|
|
240
|
+
while (Date.now() - startTime < POLL_TIMEOUT_MS) {
|
|
241
|
+
try {
|
|
242
|
+
const status = await fetchJson(`${RELAY_API_URL}/api/approvals/${approvalId}`, {
|
|
243
|
+
headers: { 'Authorization': `Bearer ${RELAY_API_KEY}` },
|
|
244
|
+
signal: AbortSignal.timeout(5000),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
if (status.status === 'allowed') {
|
|
248
|
+
log(`Approval ${approvalId} allowed`);
|
|
249
|
+
outputDecision('ALLOW', 'Remote approval granted');
|
|
250
|
+
return exit(0);
|
|
251
|
+
} else if (status.status === 'denied') {
|
|
252
|
+
log(`Approval ${approvalId} denied`);
|
|
253
|
+
outputDecision('DENY', 'Remote approval denied');
|
|
254
|
+
return exit(0);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Still pending, wait and poll again
|
|
258
|
+
await sleep(POLL_INTERVAL_MS);
|
|
259
|
+
} catch (e) {
|
|
260
|
+
log(`Poll error: ${e.message}`);
|
|
261
|
+
await sleep(POLL_INTERVAL_MS);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Timeout - let daemon handle it
|
|
266
|
+
log(`Approval timeout, handing off to daemon`);
|
|
267
|
+
outputDecision('DENY', 'Approval timeout - daemon will handle');
|
|
268
|
+
return exit(0);
|
|
269
|
+
|
|
270
|
+
} catch (e) {
|
|
271
|
+
log(`Hook error: ${e.message}`);
|
|
272
|
+
// On error, allow (fail-safe)
|
|
273
|
+
outputDecision('ALLOW', `Error: ${e.message}`);
|
|
274
|
+
return exit(0);
|
|
275
|
+
}
|
|
276
|
+
})();
|