11/**
22 * SMS Action Runner - Executes actions based on SMS responses
33 * Bridges SMS responses to Claude Code actions
4+ *
5+ * Security: Uses allowlist-based action execution to prevent command injection
46 */
57
68import { existsSync , readFileSync , writeFileSync , mkdirSync } from 'fs' ;
79import { join } from 'path' ;
810import { homedir } from 'os' ;
9- import { execSync } from 'child_process' ;
11+ import { execSync , execFileSync } from 'child_process' ;
12+ import { randomBytes } from 'crypto' ;
13+
14+ // Allowlist of safe action patterns
15+ const SAFE_ACTION_PATTERNS : Array < {
16+ pattern : RegExp ;
17+ validate ?: ( match : RegExpMatchArray ) => boolean ;
18+ } > = [
19+ // Git/GitHub CLI commands (limited to safe operations)
20+ { pattern : / ^ g h p r ( v i e w | l i s t | s t a t u s | c h e c k s ) ( \d + ) $ / } ,
21+ { pattern : / ^ g h p r r e v i e w ( \d + ) - - a p p r o v e $ / } ,
22+ { pattern : / ^ g h p r m e r g e ( \d + ) - - s q u a s h $ / } ,
23+ { pattern : / ^ g h i s s u e ( v i e w | l i s t ) ( \d + ) ? $ / } ,
24+
25+ // NPM commands (limited to safe operations)
26+ { pattern : / ^ n p m r u n ( b u i l d | t e s t | l i n t | l i n t : f i x | t e s t : r u n ) $ / } ,
27+ { pattern : / ^ n p m ( t e s t | r u n b u i l d ) $ / } ,
28+
29+ // StackMemory commands
30+ { pattern : / ^ s t a c k m e m o r y ( s t a t u s | n o t i f y c h e c k | c o n t e x t l i s t ) $ / } ,
31+
32+ // Simple echo/confirmation (no variables)
33+ { pattern : / ^ e c h o " ? ( D o n e | O K | C o n f i r m e d | A c k n o w l e d g e d ) " ? $ / } ,
34+ ] ;
35+
36+ /**
37+ * Check if an action is in the allowlist
38+ */
39+ function isActionAllowed ( action : string ) : boolean {
40+ const trimmed = action . trim ( ) ;
41+ return SAFE_ACTION_PATTERNS . some ( ( { pattern, validate } ) => {
42+ const match = trimmed . match ( pattern ) ;
43+ if ( ! match ) return false ;
44+ if ( validate && ! validate ( match ) ) return false ;
45+ return true ;
46+ } ) ;
47+ }
1048
1149export interface PendingAction {
1250 id : string ;
@@ -55,7 +93,8 @@ export function queueAction(
5593 action : string
5694) : string {
5795 const queue = loadActionQueue ( ) ;
58- const id = Math . random ( ) . toString ( 36 ) . substring ( 2 , 10 ) ;
96+ // Use cryptographically secure random ID
97+ const id = randomBytes ( 8 ) . toString ( 'hex' ) ;
5998
6099 queue . actions . push ( {
61100 id,
@@ -70,6 +109,47 @@ export function queueAction(
70109 return id ;
71110}
72111
112+ /**
113+ * Execute an action safely using allowlist validation
114+ * This prevents command injection by only allowing pre-approved commands
115+ */
116+ export function executeActionSafe (
117+ action : string ,
118+ _response : string
119+ ) : { success : boolean ; output ?: string ; error ?: string } {
120+ // Check if action is in allowlist
121+ if ( ! isActionAllowed ( action ) ) {
122+ console . error ( `[sms-action] Action not in allowlist: ${ action } ` ) ;
123+ return {
124+ success : false ,
125+ error : `Action not allowed. Only pre-approved commands can be executed via SMS.` ,
126+ } ;
127+ }
128+
129+ try {
130+ console . log ( `[sms-action] Executing safe action: ${ action } ` ) ;
131+
132+ // Parse the action into command and args
133+ const parts = action . split ( ' ' ) ;
134+ const cmd = parts [ 0 ] ;
135+ const args = parts . slice ( 1 ) ;
136+
137+ // Use execFileSync for commands without shell interpretation
138+ // This prevents shell injection even if the allowlist is somehow bypassed
139+ const output = execFileSync ( cmd , args , {
140+ encoding : 'utf8' ,
141+ timeout : 60000 ,
142+ stdio : [ 'pipe' , 'pipe' , 'pipe' ] ,
143+ shell : false , // Explicitly disable shell
144+ } ) ;
145+
146+ return { success : true , output } ;
147+ } catch ( err ) {
148+ const error = err instanceof Error ? err . message : String ( err ) ;
149+ return { success : false , error } ;
150+ }
151+ }
152+
73153export function getPendingActions ( ) : PendingAction [ ] {
74154 const queue = loadActionQueue ( ) ;
75155 return queue . actions . filter ( ( a ) => a . status === 'pending' ) ;
@@ -167,31 +247,49 @@ export function cleanupOldActions(): number {
167247
168248/**
169249 * Action Templates - Common actions for SMS responses
250+ *
251+ * SECURITY NOTE: These templates return command strings that must be
252+ * validated against SAFE_ACTION_PATTERNS before execution.
253+ * Templates that accept user input are removed to prevent injection.
170254 */
171255export const ACTION_TEMPLATES = {
172- // Git/PR actions
173- approvePR : ( prNumber : string ) =>
174- `gh pr review ${ prNumber } --approve && gh pr merge ${ prNumber } --auto` ,
175- requestChanges : ( prNumber : string ) =>
176- `gh pr review ${ prNumber } --request-changes -b "Changes requested via SMS"` ,
177- mergePR : ( prNumber : string ) => `gh pr merge ${ prNumber } --squash` ,
178- closePR : ( prNumber : string ) => `gh pr close ${ prNumber } ` ,
179-
180- // Deployment actions
181- deploy : ( env : string = 'production' ) => `npm run deploy:${ env } ` ,
182- rollback : ( env : string = 'production' ) => `npm run rollback:${ env } ` ,
183- verifyDeployment : ( url : string ) => `curl -sf ${ url } /health || exit 1` ,
184-
185- // Build actions
256+ // Git/PR actions (PR numbers must be validated as integers)
257+ approvePR : ( prNumber : string ) => {
258+ // Validate PR number is numeric only
259+ if ( ! / ^ \d + $ / . test ( prNumber ) ) {
260+ throw new Error ( 'Invalid PR number' ) ;
261+ }
262+ return `gh pr review ${ prNumber } --approve` ;
263+ } ,
264+ mergePR : ( prNumber : string ) => {
265+ if ( ! / ^ \d + $ / . test ( prNumber ) ) {
266+ throw new Error ( 'Invalid PR number' ) ;
267+ }
268+ return `gh pr merge ${ prNumber } --squash` ;
269+ } ,
270+ viewPR : ( prNumber : string ) => {
271+ if ( ! / ^ \d + $ / . test ( prNumber ) ) {
272+ throw new Error ( 'Invalid PR number' ) ;
273+ }
274+ return `gh pr view ${ prNumber } ` ;
275+ } ,
276+
277+ // Build actions (no user input)
186278 rebuild : ( ) => `npm run build` ,
187- retest : ( ) => `npm test` ,
279+ retest : ( ) => `npm run test:run ` ,
188280 lint : ( ) => `npm run lint:fix` ,
189281
190- // Notification actions
191- notifySlack : ( message : string ) =>
192- `curl -X POST $SLACK_WEBHOOK -d '{"text":"${ message } "}'` ,
193- notifyTeam : ( message : string ) =>
194- `stackmemory notify send "${ message } " --title "Team Alert"` ,
282+ // Status actions (no user input)
283+ status : ( ) => `stackmemory status` ,
284+ checkNotifications : ( ) => `stackmemory notify check` ,
285+
286+ // REMOVED for security - these templates allowed arbitrary user input:
287+ // - requestChanges (allowed arbitrary message)
288+ // - closePR (could be used maliciously)
289+ // - deploy/rollback (too dangerous for SMS)
290+ // - verifyDeployment (allowed arbitrary URL)
291+ // - notifySlack (allowed arbitrary message - command injection)
292+ // - notifyTeam (allowed arbitrary message - command injection)
195293} ;
196294
197295/**
0 commit comments