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 ;
2934
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 ;
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+ function initConfig ( options = { } ) {
41+ OPENSHELL = options . openshell || resolveOpenshell ( ) ;
42+ if ( ! OPENSHELL ) {
43+ console . error ( "openshell not found on PATH or in common locations" ) ;
44+ process . exit ( 1 ) ;
45+ }
46+
47+ TOKEN = options . token || process . env . TELEGRAM_BOT_TOKEN ;
48+ API_KEY = options . apiKey || process . env . NVIDIA_API_KEY ;
49+ SANDBOX = options . sandbox || process . env . SANDBOX_NAME || "nemoclaw" ;
50+
51+ try {
52+ validateName ( SANDBOX , "SANDBOX_NAME" ) ;
53+ } catch ( e ) {
54+ console . error ( e . message ) ;
55+ process . exit ( 1 ) ;
56+ }
3757
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 ) ; }
58+ ALLOWED_CHATS = options . allowedChats || ( process . env . ALLOWED_CHAT_IDS
59+ ? process . env . ALLOWED_CHAT_IDS . split ( "," ) . map ( ( s ) => s . trim ( ) )
60+ : null ) ;
61+
62+ if ( ! TOKEN ) {
63+ console . error ( "TELEGRAM_BOT_TOKEN required" ) ;
64+ process . exit ( 1 ) ;
65+ }
66+ if ( ! API_KEY ) {
67+ console . error ( "NVIDIA_API_KEY required" ) ;
68+ process . exit ( 1 ) ;
69+ }
70+ }
4071
4172let offset = 0 ;
4273const activeSessions = new Map ( ) ; // chatId → message history
@@ -96,34 +127,91 @@ async function sendTyping(chatId) {
96127
97128// ── Run agent inside sandbox ──────────────────────────────────────
98129
99- function runAgentInSandbox ( message , sessionId ) {
100- return new Promise ( ( resolve ) => {
101- const sshConfig = execFileSync ( OPENSHELL , [ "sandbox" , "ssh-config" , SANDBOX ] , { encoding : "utf-8" } ) ;
130+ /**
131+ * Sanitize session ID to contain only alphanumeric characters and hyphens.
132+ * Returns null if the result is empty after sanitization.
133+ * @param {string|number } sessionId - The session ID to sanitize
134+ * @returns {string|null } - Sanitized session ID or null if empty
135+ */
136+ function sanitizeSessionId ( sessionId ) {
137+ const sanitized = String ( sessionId ) . replace ( / [ ^ a - z A - Z 0 - 9 - ] / g, "" ) ;
138+ return sanitized . length > 0 ? sanitized : null ;
139+ }
102140
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 } ) ;
141+ /**
142+ * Run the OpenClaw agent inside the sandbox with the given message.
143+ *
144+ * SECURITY: This function passes user messages and API credentials via stdin
145+ * instead of shell string interpolation to prevent command injection attacks.
146+ * The remote script reads the API key from the first line of stdin and the
147+ * message from the remaining stdin, then uses them in double-quoted variables
148+ * which prevents shell interpretation.
149+ *
150+ * @param {string } message - The user message to send to the agent
151+ * @param {string|number } sessionId - The session identifier (typically Telegram chat ID)
152+ * @param {object } options - Optional overrides for testing
153+ * @param {string } options.apiKey - Override API key (defaults to process.env.NVIDIA_API_KEY)
154+ * @param {string } options.sandbox - Override sandbox name (defaults to SANDBOX)
155+ * @param {string } options.openshell - Override openshell path (defaults to OPENSHELL)
156+ * @returns {Promise<string> } - The agent's response
157+ */
158+ function runAgentInSandbox ( message , sessionId , options = { } ) {
159+ const apiKey = options . apiKey || API_KEY ;
160+ const sandbox = options . sandbox || SANDBOX ;
161+ const openshell = options . openshell || OPENSHELL ;
107162
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 ) } ` ;
163+ return new Promise ( ( resolve ) => {
164+ // Sanitize session ID - reject if empty after sanitization
165+ const safeSessionId = sanitizeSessionId ( sessionId ) ;
166+ if ( ! safeSessionId ) {
167+ resolve ( "Error: Invalid session ID" ) ;
168+ return ;
169+ }
170+
171+ // Get SSH config using execFileSync (no shell interpretation)
172+ const sshConfig = execFileSync ( openshell , [ "sandbox" , "ssh-config" , sandbox ] , { encoding : "utf-8" } ) ;
113173
114- const proc = spawn ( "ssh" , [ "-T" , "-F" , confPath , `openshell-${ SANDBOX } ` , cmd ] , {
174+ // Write temp ssh config with cryptographically unpredictable path
175+ // to prevent symlink race attacks (CWE-377)
176+ const confDir = fs . mkdtempSync ( "/tmp/nemoclaw-tg-ssh-" ) ;
177+ const confPath = `${ confDir } /config` ;
178+ fs . writeFileSync ( confPath , sshConfig , { mode : 0o600 } ) ;
179+
180+ // SECURITY FIX: Pass API key and message via stdin instead of shell interpolation.
181+ // The remote script:
182+ // 1. Reads API key from first line of stdin
183+ // 2. Exports it as environment variable
184+ // 3. Reads message from remaining stdin
185+ // 4. Passes message to nemoclaw-start in double quotes (no shell expansion)
186+ const remoteScript = [
187+ "read -r NVIDIA_API_KEY" ,
188+ "export NVIDIA_API_KEY" ,
189+ "MSG=$(cat)" ,
190+ `exec nemoclaw-start openclaw agent --agent main --local -m "$MSG" --session-id "tg-${ safeSessionId } "` ,
191+ ] . join ( " && " ) ;
192+
193+ const proc = spawn ( "ssh" , [ "-T" , "-F" , confPath , `openshell-${ sandbox } ` , remoteScript ] , {
115194 timeout : 120000 ,
116- stdio : [ "ignore " , "pipe" , "pipe" ] ,
195+ stdio : [ "pipe " , "pipe" , "pipe" ] , // Enable stdin
117196 } ) ;
118197
198+ // Write API key (first line) and message (remaining) to stdin
199+ proc . stdin . write ( apiKey + "\n" ) ;
200+ proc . stdin . write ( message ) ;
201+ proc . stdin . end ( ) ;
202+
119203 let stdout = "" ;
120204 let stderr = "" ;
121205
122206 proc . stdout . on ( "data" , ( d ) => ( stdout += d . toString ( ) ) ) ;
123207 proc . stderr . on ( "data" , ( d ) => ( stderr += d . toString ( ) ) ) ;
124208
125209 proc . on ( "close" , ( code ) => {
126- try { require ( "fs" ) . unlinkSync ( confPath ) ; require ( "fs" ) . rmdirSync ( confDir ) ; } catch { /* ignored */ }
210+ // Clean up temp files
211+ try {
212+ fs . unlinkSync ( confPath ) ;
213+ fs . rmdirSync ( confDir ) ;
214+ } catch { /* ignored */ }
127215
128216 // Extract the actual agent response — skip setup lines
129217 const lines = stdout . split ( "\n" ) ;
@@ -153,6 +241,11 @@ function runAgentInSandbox(message, sessionId) {
153241 } ) ;
154242
155243 proc . on ( "error" , ( err ) => {
244+ // Clean up temp files on error
245+ try {
246+ fs . unlinkSync ( confPath ) ;
247+ fs . rmdirSync ( confDir ) ;
248+ } catch { /* ignored */ }
156249 resolve ( `Error: ${ err . message } ` ) ;
157250 } ) ;
158251 } ) ;
@@ -202,6 +295,16 @@ async function poll() {
202295 continue ;
203296 }
204297
298+ // Message length validation
299+ if ( msg . text . length > MAX_MESSAGE_LENGTH ) {
300+ await sendMessage (
301+ chatId ,
302+ `Message too long (${ msg . text . length } chars). Maximum is ${ MAX_MESSAGE_LENGTH } characters.` ,
303+ msg . message_id ,
304+ ) ;
305+ continue ;
306+ }
307+
205308 // Rate limiting: per-chat cooldown
206309 const now = Date . now ( ) ;
207310 const lastTime = lastMessageTime . get ( chatId ) || 0 ;
@@ -250,6 +353,9 @@ async function poll() {
250353// ── Main ──────────────────────────────────────────────────────────
251354
252355async function main ( ) {
356+ // Initialize configuration from environment
357+ initConfig ( ) ;
358+
253359 const me = await tgApi ( "getMe" , { } ) ;
254360 if ( ! me . ok ) {
255361 console . error ( "Failed to connect to Telegram:" , JSON . stringify ( me ) ) ;
@@ -273,4 +379,15 @@ async function main() {
273379 poll ( ) ;
274380}
275381
276- main ( ) ;
382+ // Only run main() if this is the entry point (not imported for testing)
383+ if ( require . main === module ) {
384+ main ( ) ;
385+ }
386+
387+ // Export for testing
388+ module . exports = {
389+ runAgentInSandbox,
390+ sanitizeSessionId,
391+ initConfig,
392+ MAX_MESSAGE_LENGTH ,
393+ } ;
0 commit comments