1717 */
1818
1919const https = require ( "https" ) ;
20+ const fs = require ( "fs" ) ;
2021const { execFileSync, spawn } = require ( "child_process" ) ;
2122const { resolveOpenshell } = require ( "../bin/lib/resolve-openshell" ) ;
22- const { shellQuote , validateName } = require ( "../bin/lib/runner" ) ;
23+ const { validateName } = require ( "../bin/lib/runner" ) ;
2324
24- const OPENSHELL = resolveOpenshell ( ) ;
25- if ( ! OPENSHELL ) {
26- console . error ( "openshell not found on PATH or in common locations" ) ;
27- process . exit ( 1 ) ;
28- }
25+ // Maximum message length (matches Telegram's limit)
26+ const MAX_MESSAGE_LENGTH = 4096 ;
27+
28+ // Configuration - validated at startup when running as main module
29+ let OPENSHELL = null ;
30+ let TOKEN = null ;
31+ let API_KEY = null ;
32+ let SANDBOX = null ;
33+ let ALLOWED_CHATS = null ;
34+
35+ /**
36+ * Initialize configuration from environment variables.
37+ * Called automatically when running as main module.
38+ * Can be called manually for testing with custom values.
39+ *
40+ * @param {object } [options] - Optional overrides for testing
41+ * @param {string } [options.openshell] - Override openshell path
42+ * @param {string } [options.token] - Override Telegram bot token
43+ * @param {string } [options.apiKey] - Override NVIDIA API key
44+ * @param {string } [options.sandbox] - Override sandbox name
45+ * @param {string[] } [options.allowedChats] - Override allowed chat IDs
46+ * @param {boolean } [options.exitOnError=true] - Whether to exit on validation errors (set false for testing)
47+ * @returns {object } - The resolved configuration object
48+ */
49+ function initConfig ( options = { } ) {
50+ const exitOnError = options . exitOnError !== false ;
51+
52+ OPENSHELL = options . openshell || resolveOpenshell ( ) ;
53+ if ( ! OPENSHELL ) {
54+ if ( exitOnError ) {
55+ console . error ( "openshell not found on PATH or in common locations" ) ;
56+ process . exit ( 1 ) ;
57+ }
58+ throw new Error ( "openshell not found on PATH or in common locations" ) ;
59+ }
60+
61+ TOKEN = options . token || process . env . TELEGRAM_BOT_TOKEN ;
62+ API_KEY = options . apiKey || process . env . NVIDIA_API_KEY ;
63+ SANDBOX = options . sandbox || process . env . SANDBOX_NAME || "nemoclaw" ;
2964
30- const TOKEN = process . env . TELEGRAM_BOT_TOKEN ;
31- const API_KEY = process . env . NVIDIA_API_KEY ;
32- const SANDBOX = process . env . SANDBOX_NAME || "nemoclaw" ;
33- try { validateName ( SANDBOX , "SANDBOX_NAME" ) ; } catch ( e ) { console . error ( e . message ) ; process . exit ( 1 ) ; }
34- const ALLOWED_CHATS = process . env . ALLOWED_CHAT_IDS
35- ? process . env . ALLOWED_CHAT_IDS . split ( "," ) . map ( ( s ) => s . trim ( ) )
36- : null ;
65+ try {
66+ validateName ( SANDBOX , "SANDBOX_NAME" ) ;
67+ } catch ( e ) {
68+ if ( exitOnError ) {
69+ console . error ( e . message ) ;
70+ process . exit ( 1 ) ;
71+ }
72+ throw e ;
73+ }
3774
38- if ( ! TOKEN ) { console . error ( "TELEGRAM_BOT_TOKEN required" ) ; process . exit ( 1 ) ; }
39- if ( ! API_KEY ) { console . error ( "NVIDIA_API_KEY required" ) ; process . exit ( 1 ) ; }
75+ ALLOWED_CHATS = options . allowedChats || ( process . env . ALLOWED_CHAT_IDS
76+ ? process . env . ALLOWED_CHAT_IDS . split ( "," ) . map ( ( s ) => s . trim ( ) )
77+ : null ) ;
78+
79+ if ( ! TOKEN ) {
80+ if ( exitOnError ) {
81+ console . error ( "TELEGRAM_BOT_TOKEN required" ) ;
82+ process . exit ( 1 ) ;
83+ }
84+ throw new Error ( "TELEGRAM_BOT_TOKEN required" ) ;
85+ }
86+ if ( ! API_KEY ) {
87+ if ( exitOnError ) {
88+ console . error ( "NVIDIA_API_KEY required" ) ;
89+ process . exit ( 1 ) ;
90+ }
91+ throw new Error ( "NVIDIA_API_KEY required" ) ;
92+ }
93+
94+ return { OPENSHELL , TOKEN , API_KEY , SANDBOX , ALLOWED_CHATS } ;
95+ }
4096
4197let offset = 0 ;
4298const activeSessions = new Map ( ) ; // chatId → message history
@@ -96,34 +152,101 @@ async function sendTyping(chatId) {
96152
97153// ── Run agent inside sandbox ──────────────────────────────────────
98154
99- function runAgentInSandbox ( message , sessionId ) {
100- return new Promise ( ( resolve ) => {
101- const sshConfig = execFileSync ( OPENSHELL , [ "sandbox" , "ssh-config" , SANDBOX ] , { encoding : "utf-8" } ) ;
155+ /**
156+ * Sanitize session ID to contain only alphanumeric characters and hyphens.
157+ * Returns null if the result is empty after sanitization.
158+ *
159+ * SECURITY: Leading hyphens are stripped to prevent option injection attacks.
160+ * For example, a session ID of "--help" would otherwise be interpreted as a
161+ * command-line flag by nemoclaw-start. This differs from SANDBOX_NAME validation
162+ * (which uses validateName() requiring lowercase alphanumeric with internal hyphens)
163+ * because session IDs come from Telegram chat IDs which can be negative numbers.
164+ *
165+ * @param {string|number } sessionId - The session ID to sanitize
166+ * @returns {string|null } - Sanitized session ID or null if empty
167+ */
168+ function sanitizeSessionId ( sessionId ) {
169+ // Strip all non-alphanumeric characters except hyphens
170+ const sanitized = String ( sessionId ) . replace ( / [ ^ a - z A - Z 0 - 9 - ] / g, "" ) ;
171+ // Remove leading hyphens to prevent option injection (e.g., "--help", "-v")
172+ const noLeadingHyphen = sanitized . replace ( / ^ - + / , "" ) ;
173+ return noLeadingHyphen . length > 0 ? noLeadingHyphen : null ;
174+ }
102175
103- // Write temp ssh config with unpredictable name
104- const confDir = require ( "fs" ) . mkdtempSync ( "/tmp/nemoclaw-tg-ssh-" ) ;
105- const confPath = `${ confDir } /config` ;
106- require ( "fs" ) . writeFileSync ( confPath , sshConfig , { mode : 0o600 } ) ;
176+ /**
177+ * Run the OpenClaw agent inside the sandbox with the given message.
178+ *
179+ * SECURITY: This function passes user messages and API credentials via stdin
180+ * instead of shell string interpolation to prevent command injection attacks.
181+ * The remote script reads the API key from the first line of stdin and the
182+ * message from the remaining stdin, then uses them in double-quoted variables
183+ * which prevents shell interpretation.
184+ *
185+ * @param {string } message - The user message to send to the agent
186+ * @param {string|number } sessionId - The session identifier (typically Telegram chat ID)
187+ * @param {object } [options] - Optional overrides for testing
188+ * @param {string } [options.apiKey] - Override API key (defaults to process.env.NVIDIA_API_KEY)
189+ * @param {string } [options.sandbox] - Override sandbox name (defaults to SANDBOX)
190+ * @param {string } [options.openshell] - Override openshell path (defaults to OPENSHELL)
191+ * @returns {Promise<string> } - The agent's response
192+ */
193+ function runAgentInSandbox ( message , sessionId , options = { } ) {
194+ const apiKey = options . apiKey || API_KEY ;
195+ const sandbox = options . sandbox || SANDBOX ;
196+ const openshell = options . openshell || OPENSHELL ;
107197
108- // Pass message and API key via stdin to avoid shell interpolation.
109- // The remote command reads them from environment/stdin rather than
110- // embedding user content in a shell string.
111- const safeSessionId = String ( sessionId ) . replace ( / [ ^ a - z A - Z 0 - 9 - ] / g, "" ) ;
112- const cmd = `export NVIDIA_API_KEY=${ shellQuote ( API_KEY ) } && nemoclaw-start openclaw agent --agent main --local -m ${ shellQuote ( message ) } --session-id ${ shellQuote ( "tg-" + safeSessionId ) } ` ;
198+ return new Promise ( ( resolve ) => {
199+ // Sanitize session ID - reject if empty after sanitization
200+ const safeSessionId = sanitizeSessionId ( sessionId ) ;
201+ if ( ! safeSessionId ) {
202+ resolve ( "Error: Invalid session ID" ) ;
203+ return ;
204+ }
113205
114- const proc = spawn ( "ssh" , [ "-T" , "-F" , confPath , `openshell-${ SANDBOX } ` , cmd ] , {
206+ // Get SSH config using execFileSync (no shell interpretation)
207+ const sshConfig = execFileSync ( openshell , [ "sandbox" , "ssh-config" , sandbox ] , { encoding : "utf-8" } ) ;
208+
209+ // Write temp ssh config with cryptographically unpredictable path
210+ // to prevent symlink race attacks (CWE-377)
211+ const confDir = fs . mkdtempSync ( "/tmp/nemoclaw-tg-ssh-" ) ;
212+ const confPath = `${ confDir } /config` ;
213+ fs . writeFileSync ( confPath , sshConfig , { mode : 0o600 } ) ;
214+
215+ // SECURITY FIX: Pass API key and message via stdin instead of shell interpolation.
216+ // The remote script:
217+ // 1. Reads API key from first line of stdin
218+ // 2. Exports it as environment variable
219+ // 3. Reads message from remaining stdin
220+ // 4. Passes message to nemoclaw-start in double quotes (no shell expansion)
221+ const remoteScript = [
222+ "read -r NVIDIA_API_KEY" ,
223+ "export NVIDIA_API_KEY" ,
224+ "MSG=$(cat)" ,
225+ `exec nemoclaw-start openclaw agent --agent main --local -m "$MSG" --session-id "tg-${ safeSessionId } "` ,
226+ ] . join ( " && " ) ;
227+
228+ const proc = spawn ( "ssh" , [ "-T" , "-F" , confPath , `openshell-${ sandbox } ` , remoteScript ] , {
115229 timeout : 120000 ,
116- stdio : [ "ignore " , "pipe" , "pipe" ] ,
230+ stdio : [ "pipe " , "pipe" , "pipe" ] , // Enable stdin
117231 } ) ;
118232
233+ // Write API key (first line) and message (remaining) to stdin
234+ proc . stdin . write ( apiKey + "\n" ) ;
235+ proc . stdin . write ( message ) ;
236+ proc . stdin . end ( ) ;
237+
119238 let stdout = "" ;
120239 let stderr = "" ;
121240
122241 proc . stdout . on ( "data" , ( d ) => ( stdout += d . toString ( ) ) ) ;
123242 proc . stderr . on ( "data" , ( d ) => ( stderr += d . toString ( ) ) ) ;
124243
125244 proc . on ( "close" , ( code ) => {
126- try { require ( "fs" ) . unlinkSync ( confPath ) ; require ( "fs" ) . rmdirSync ( confDir ) ; } catch { /* ignored */ }
245+ // Clean up temp files
246+ try {
247+ fs . unlinkSync ( confPath ) ;
248+ fs . rmdirSync ( confDir ) ;
249+ } catch { /* ignored */ }
127250
128251 // Extract the actual agent response — skip setup lines
129252 const lines = stdout . split ( "\n" ) ;
@@ -153,6 +276,11 @@ function runAgentInSandbox(message, sessionId) {
153276 } ) ;
154277
155278 proc . on ( "error" , ( err ) => {
279+ // Clean up temp files on error
280+ try {
281+ fs . unlinkSync ( confPath ) ;
282+ fs . rmdirSync ( confDir ) ;
283+ } catch { /* ignored */ }
156284 resolve ( `Error: ${ err . message } ` ) ;
157285 } ) ;
158286 } ) ;
@@ -202,6 +330,16 @@ async function poll() {
202330 continue ;
203331 }
204332
333+ // Message length validation
334+ if ( msg . text . length > MAX_MESSAGE_LENGTH ) {
335+ await sendMessage (
336+ chatId ,
337+ `Message too long (${ msg . text . length } chars). Maximum is ${ MAX_MESSAGE_LENGTH } characters.` ,
338+ msg . message_id ,
339+ ) ;
340+ continue ;
341+ }
342+
205343 // Rate limiting: per-chat cooldown
206344 const now = Date . now ( ) ;
207345 const lastTime = lastMessageTime . get ( chatId ) || 0 ;
@@ -250,6 +388,9 @@ async function poll() {
250388// ── Main ──────────────────────────────────────────────────────────
251389
252390async function main ( ) {
391+ // Initialize configuration from environment
392+ initConfig ( ) ;
393+
253394 const me = await tgApi ( "getMe" , { } ) ;
254395 if ( ! me . ok ) {
255396 console . error ( "Failed to connect to Telegram:" , JSON . stringify ( me ) ) ;
@@ -273,4 +414,15 @@ async function main() {
273414 poll ( ) ;
274415}
275416
276- main ( ) ;
417+ // Only run main() if this is the entry point (not imported for testing)
418+ if ( require . main === module ) {
419+ main ( ) ;
420+ }
421+
422+ // Export for testing
423+ module . exports = {
424+ runAgentInSandbox,
425+ sanitizeSessionId,
426+ initConfig,
427+ MAX_MESSAGE_LENGTH ,
428+ } ;
0 commit comments