@stackmemoryai/stackmemory 0.5.21 → 0.5.23
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/dist/cli/claude-sm.js +94 -18
- package/dist/cli/claude-sm.js.map +2 -2
- package/dist/cli/commands/retrieval.js +248 -0
- package/dist/cli/commands/retrieval.js.map +7 -0
- package/dist/cli/index.js +2 -0
- package/dist/cli/index.js.map +2 -2
- package/dist/core/retrieval/llm-context-retrieval.js +36 -2
- package/dist/core/retrieval/llm-context-retrieval.js.map +3 -3
- package/dist/core/retrieval/llm-provider.js +128 -0
- package/dist/core/retrieval/llm-provider.js.map +7 -0
- package/dist/core/retrieval/retrieval-audit.js +236 -0
- package/dist/core/retrieval/retrieval-audit.js.map +7 -0
- package/dist/hooks/linear-task-picker.js +180 -0
- package/dist/hooks/linear-task-picker.js.map +7 -0
- package/dist/hooks/session-summary.js +197 -0
- package/dist/hooks/session-summary.js.map +7 -0
- package/dist/hooks/sms-action-runner.js +87 -20
- package/dist/hooks/sms-action-runner.js.map +2 -2
- package/dist/hooks/sms-webhook.js +6 -6
- package/dist/hooks/sms-webhook.js.map +2 -2
- package/dist/integrations/linear/client.js +125 -0
- package/dist/integrations/linear/client.js.map +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { fileURLToPath as __fileURLToPath } from 'url';
|
|
2
|
+
import { dirname as __pathDirname } from 'path';
|
|
3
|
+
const __filename = __fileURLToPath(import.meta.url);
|
|
4
|
+
const __dirname = __pathDirname(__filename);
|
|
5
|
+
import { execSync } from "child_process";
|
|
6
|
+
import { pickNextLinearTask } from "./linear-task-picker.js";
|
|
7
|
+
function formatDuration(ms) {
|
|
8
|
+
const seconds = Math.floor(ms / 1e3);
|
|
9
|
+
const minutes = Math.floor(seconds / 60);
|
|
10
|
+
const hours = Math.floor(minutes / 60);
|
|
11
|
+
if (hours > 0) {
|
|
12
|
+
return `${hours}h ${minutes % 60}min`;
|
|
13
|
+
}
|
|
14
|
+
if (minutes > 0) {
|
|
15
|
+
return `${minutes}min`;
|
|
16
|
+
}
|
|
17
|
+
return `${seconds}s`;
|
|
18
|
+
}
|
|
19
|
+
function getCurrentBranch() {
|
|
20
|
+
try {
|
|
21
|
+
return execSync("git rev-parse --abbrev-ref HEAD", {
|
|
22
|
+
encoding: "utf8",
|
|
23
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
24
|
+
}).trim();
|
|
25
|
+
} catch {
|
|
26
|
+
return "unknown";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function hasUncommittedChanges() {
|
|
30
|
+
try {
|
|
31
|
+
const status = execSync("git status --porcelain", {
|
|
32
|
+
encoding: "utf8",
|
|
33
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
34
|
+
});
|
|
35
|
+
const lines = status.trim().split("\n").filter(Boolean);
|
|
36
|
+
return { changed: lines.length > 0, count: lines.length };
|
|
37
|
+
} catch {
|
|
38
|
+
return { changed: false, count: 0 };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function isInWorktree() {
|
|
42
|
+
try {
|
|
43
|
+
execSync("git rev-parse --is-inside-work-tree", {
|
|
44
|
+
encoding: "utf8",
|
|
45
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
46
|
+
});
|
|
47
|
+
const gitDir = execSync("git rev-parse --git-dir", {
|
|
48
|
+
encoding: "utf8",
|
|
49
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
50
|
+
}).trim();
|
|
51
|
+
return gitDir.includes(".git/worktrees/");
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function hasTestScript() {
|
|
57
|
+
try {
|
|
58
|
+
const packageJson = execSync("cat package.json", {
|
|
59
|
+
encoding: "utf8",
|
|
60
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
61
|
+
});
|
|
62
|
+
const pkg = JSON.parse(packageJson);
|
|
63
|
+
return !!(pkg.scripts?.test || pkg.scripts?.["test:run"]);
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function generateSuggestions(context) {
|
|
69
|
+
const suggestions = [];
|
|
70
|
+
let keyIndex = 1;
|
|
71
|
+
const changes = hasUncommittedChanges();
|
|
72
|
+
const inWorktree = isInWorktree();
|
|
73
|
+
const hasTests = hasTestScript();
|
|
74
|
+
if (context.exitCode !== 0 && context.exitCode !== null) {
|
|
75
|
+
suggestions.push({
|
|
76
|
+
key: String(keyIndex++),
|
|
77
|
+
label: "Review error logs",
|
|
78
|
+
action: "cat ~/.claude/logs/claude-*.log | tail -50",
|
|
79
|
+
priority: 100
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (changes.changed) {
|
|
83
|
+
suggestions.push({
|
|
84
|
+
key: String(keyIndex++),
|
|
85
|
+
label: `Commit changes (${changes.count} files)`,
|
|
86
|
+
action: "git add -A && git commit",
|
|
87
|
+
priority: 90
|
|
88
|
+
});
|
|
89
|
+
const branch = getCurrentBranch();
|
|
90
|
+
if (branch !== "main" && branch !== "master" && branch !== "unknown") {
|
|
91
|
+
suggestions.push({
|
|
92
|
+
key: String(keyIndex++),
|
|
93
|
+
label: "Create PR",
|
|
94
|
+
action: "gh pr create --fill",
|
|
95
|
+
priority: 80
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (hasTests && changes.changed) {
|
|
100
|
+
suggestions.push({
|
|
101
|
+
key: String(keyIndex++),
|
|
102
|
+
label: "Run tests",
|
|
103
|
+
action: "npm run test:run",
|
|
104
|
+
priority: 85
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
if (inWorktree) {
|
|
108
|
+
suggestions.push({
|
|
109
|
+
key: String(keyIndex++),
|
|
110
|
+
label: "Merge to main",
|
|
111
|
+
action: "cwm",
|
|
112
|
+
// custom alias
|
|
113
|
+
priority: 70
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const linearTask = await pickNextLinearTask({ preferTestTasks: true });
|
|
118
|
+
if (linearTask) {
|
|
119
|
+
suggestions.push({
|
|
120
|
+
key: String(keyIndex++),
|
|
121
|
+
label: `Start: ${linearTask.identifier} - ${linearTask.title.substring(0, 40)}${linearTask.title.length > 40 ? "..." : ""}${linearTask.hasTestRequirements ? " (has tests)" : ""}`,
|
|
122
|
+
action: `stackmemory task start ${linearTask.id} --assign-me`,
|
|
123
|
+
priority: 60
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
}
|
|
128
|
+
const durationMs = Date.now() - context.sessionStartTime;
|
|
129
|
+
if (durationMs > 30 * 60 * 1e3) {
|
|
130
|
+
suggestions.push({
|
|
131
|
+
key: String(keyIndex++),
|
|
132
|
+
label: "Take a break",
|
|
133
|
+
action: 'echo "Great work! Time for a coffee break."',
|
|
134
|
+
priority: 10
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
suggestions.sort((a, b) => b.priority - a.priority);
|
|
138
|
+
suggestions.forEach((s, i) => {
|
|
139
|
+
s.key = String(i + 1);
|
|
140
|
+
});
|
|
141
|
+
return suggestions;
|
|
142
|
+
}
|
|
143
|
+
async function generateSessionSummary(context) {
|
|
144
|
+
const durationMs = Date.now() - context.sessionStartTime;
|
|
145
|
+
const duration = formatDuration(durationMs);
|
|
146
|
+
const branch = context.branch || getCurrentBranch();
|
|
147
|
+
let status = "success";
|
|
148
|
+
if (context.exitCode !== 0 && context.exitCode !== null) {
|
|
149
|
+
status = "error";
|
|
150
|
+
}
|
|
151
|
+
const suggestions = await generateSuggestions(context);
|
|
152
|
+
let linearTask;
|
|
153
|
+
try {
|
|
154
|
+
linearTask = await pickNextLinearTask({ preferTestTasks: true });
|
|
155
|
+
} catch {
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
duration,
|
|
159
|
+
exitCode: context.exitCode,
|
|
160
|
+
branch,
|
|
161
|
+
status,
|
|
162
|
+
suggestions,
|
|
163
|
+
linearTask
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function formatSummaryMessage(summary) {
|
|
167
|
+
const statusEmoji = summary.status === "success" ? "" : "";
|
|
168
|
+
const exitInfo = summary.exitCode !== null ? ` | Exit: ${summary.exitCode}` : "";
|
|
169
|
+
let message = `Claude session complete ${statusEmoji}
|
|
170
|
+
`;
|
|
171
|
+
message += `Duration: ${summary.duration}${exitInfo} | Branch: ${summary.branch}
|
|
172
|
+
|
|
173
|
+
`;
|
|
174
|
+
if (summary.suggestions.length > 0) {
|
|
175
|
+
message += `What to do next:
|
|
176
|
+
`;
|
|
177
|
+
for (const s of summary.suggestions.slice(0, 4)) {
|
|
178
|
+
message += `${s.key}. ${s.label}
|
|
179
|
+
`;
|
|
180
|
+
}
|
|
181
|
+
message += `
|
|
182
|
+
Reply with number or custom action`;
|
|
183
|
+
} else {
|
|
184
|
+
message += `No pending actions. Nice work!`;
|
|
185
|
+
}
|
|
186
|
+
return message;
|
|
187
|
+
}
|
|
188
|
+
function getActionForKey(suggestions, key) {
|
|
189
|
+
const suggestion = suggestions.find((s) => s.key === key);
|
|
190
|
+
return suggestion?.action || null;
|
|
191
|
+
}
|
|
192
|
+
export {
|
|
193
|
+
formatSummaryMessage,
|
|
194
|
+
generateSessionSummary,
|
|
195
|
+
getActionForKey
|
|
196
|
+
};
|
|
197
|
+
//# sourceMappingURL=session-summary.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/hooks/session-summary.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Session Summary Generator\n * Generates intelligent suggestions for what to do next after a Claude session\n */\n\nimport { execSync } from 'child_process';\nimport { pickNextLinearTask, TaskSuggestion } from './linear-task-picker.js';\n\nexport interface SessionContext {\n instanceId: string;\n exitCode: number | null;\n sessionStartTime: number;\n worktreePath?: string;\n branch?: string;\n task?: string;\n}\n\nexport interface Suggestion {\n key: string;\n label: string;\n action: string;\n priority: number;\n}\n\nexport interface SessionSummary {\n duration: string;\n exitCode: number | null;\n branch: string;\n status: 'success' | 'error' | 'interrupted';\n suggestions: Suggestion[];\n linearTask?: TaskSuggestion;\n}\n\n/**\n * Format duration in human-readable form\n */\nfunction formatDuration(ms: number): string {\n const seconds = Math.floor(ms / 1000);\n const minutes = Math.floor(seconds / 60);\n const hours = Math.floor(minutes / 60);\n\n if (hours > 0) {\n return `${hours}h ${minutes % 60}min`;\n }\n if (minutes > 0) {\n return `${minutes}min`;\n }\n return `${seconds}s`;\n}\n\n/**\n * Get current git branch\n */\nfunction getCurrentBranch(): string {\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch {\n return 'unknown';\n }\n}\n\n/**\n * Check for uncommitted changes\n */\nfunction hasUncommittedChanges(): { changed: boolean; count: number } {\n try {\n const status = execSync('git status --porcelain', {\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n });\n const lines = status.trim().split('\\n').filter(Boolean);\n return { changed: lines.length > 0, count: lines.length };\n } catch {\n return { changed: false, count: 0 };\n }\n}\n\n/**\n * Check if we're in a worktree\n */\nfunction isInWorktree(): boolean {\n try {\n execSync('git rev-parse --is-inside-work-tree', {\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n });\n // Check if it's a worktree (not the main repo)\n const gitDir = execSync('git rev-parse --git-dir', {\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return gitDir.includes('.git/worktrees/');\n } catch {\n return false;\n }\n}\n\n/**\n * Check if tests exist and might need running\n */\nfunction hasTestScript(): boolean {\n try {\n const packageJson = execSync('cat package.json', {\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n });\n const pkg = JSON.parse(packageJson);\n return !!(pkg.scripts?.test || pkg.scripts?.['test:run']);\n } catch {\n return false;\n }\n}\n\n/**\n * Generate suggestions based on session context\n */\nasync function generateSuggestions(\n context: SessionContext\n): Promise<Suggestion[]> {\n const suggestions: Suggestion[] = [];\n let keyIndex = 1;\n\n const changes = hasUncommittedChanges();\n const inWorktree = isInWorktree();\n const hasTests = hasTestScript();\n\n // Error case - suggest reviewing logs\n if (context.exitCode !== 0 && context.exitCode !== null) {\n suggestions.push({\n key: String(keyIndex++),\n label: 'Review error logs',\n action: 'cat ~/.claude/logs/claude-*.log | tail -50',\n priority: 100,\n });\n }\n\n // Uncommitted changes - suggest commit or PR\n if (changes.changed) {\n suggestions.push({\n key: String(keyIndex++),\n label: `Commit changes (${changes.count} files)`,\n action: 'git add -A && git commit',\n priority: 90,\n });\n\n // If on feature branch, suggest PR\n const branch = getCurrentBranch();\n if (branch !== 'main' && branch !== 'master' && branch !== 'unknown') {\n suggestions.push({\n key: String(keyIndex++),\n label: 'Create PR',\n action: 'gh pr create --fill',\n priority: 80,\n });\n }\n }\n\n // If tests exist and changes were made, suggest running tests\n if (hasTests && changes.changed) {\n suggestions.push({\n key: String(keyIndex++),\n label: 'Run tests',\n action: 'npm run test:run',\n priority: 85,\n });\n }\n\n // Worktree-specific suggestions\n if (inWorktree) {\n suggestions.push({\n key: String(keyIndex++),\n label: 'Merge to main',\n action: 'cwm', // custom alias\n priority: 70,\n });\n }\n\n // Try to get next Linear task\n try {\n const linearTask = await pickNextLinearTask({ preferTestTasks: true });\n if (linearTask) {\n suggestions.push({\n key: String(keyIndex++),\n label: `Start: ${linearTask.identifier} - ${linearTask.title.substring(0, 40)}${linearTask.title.length > 40 ? '...' : ''}${linearTask.hasTestRequirements ? ' (has tests)' : ''}`,\n action: `stackmemory task start ${linearTask.id} --assign-me`,\n priority: 60,\n });\n }\n } catch {\n // Linear not available, skip\n }\n\n // Long session suggestion\n const durationMs = Date.now() - context.sessionStartTime;\n if (durationMs > 30 * 60 * 1000) {\n // > 30 minutes\n suggestions.push({\n key: String(keyIndex++),\n label: 'Take a break',\n action: 'echo \"Great work! Time for a coffee break.\"',\n priority: 10,\n });\n }\n\n // Sort by priority (highest first) and re-key\n suggestions.sort((a, b) => b.priority - a.priority);\n suggestions.forEach((s, i) => {\n s.key = String(i + 1);\n });\n\n return suggestions;\n}\n\n/**\n * Generate full session summary\n */\nexport async function generateSessionSummary(\n context: SessionContext\n): Promise<SessionSummary> {\n const durationMs = Date.now() - context.sessionStartTime;\n const duration = formatDuration(durationMs);\n const branch = context.branch || getCurrentBranch();\n\n let status: 'success' | 'error' | 'interrupted' = 'success';\n if (context.exitCode !== 0 && context.exitCode !== null) {\n status = 'error';\n }\n\n const suggestions = await generateSuggestions(context);\n\n // Extract linear task if present\n let linearTask: TaskSuggestion | undefined;\n try {\n linearTask = await pickNextLinearTask({ preferTestTasks: true });\n } catch {\n // Linear not available\n }\n\n return {\n duration,\n exitCode: context.exitCode,\n branch,\n status,\n suggestions,\n linearTask,\n };\n}\n\n/**\n * Format session summary as WhatsApp message\n */\nexport function formatSummaryMessage(summary: SessionSummary): string {\n const statusEmoji = summary.status === 'success' ? '' : '';\n const exitInfo =\n summary.exitCode !== null ? ` | Exit: ${summary.exitCode}` : '';\n\n let message = `Claude session complete ${statusEmoji}\\n`;\n message += `Duration: ${summary.duration}${exitInfo} | Branch: ${summary.branch}\\n\\n`;\n\n if (summary.suggestions.length > 0) {\n message += `What to do next:\\n`;\n for (const s of summary.suggestions.slice(0, 4)) {\n message += `${s.key}. ${s.label}\\n`;\n }\n message += `\\nReply with number or custom action`;\n } else {\n message += `No pending actions. Nice work!`;\n }\n\n return message;\n}\n\n/**\n * Get action for a suggestion key\n */\nexport function getActionForKey(\n suggestions: Suggestion[],\n key: string\n): string | null {\n const suggestion = suggestions.find((s) => s.key === key);\n return suggestion?.action || null;\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -5,10 +5,12 @@ const __dirname = __pathDirname(__filename);
|
|
|
5
5
|
import { existsSync, readFileSync } from "fs";
|
|
6
6
|
import { join } from "path";
|
|
7
7
|
import { homedir } from "os";
|
|
8
|
-
import {
|
|
8
|
+
import { execFileSync } from "child_process";
|
|
9
9
|
import { randomBytes } from "crypto";
|
|
10
10
|
import { writeFileSecure, ensureSecureDir } from "./secure-fs.js";
|
|
11
11
|
import { ActionQueueSchema, parseConfigSafe } from "./schemas.js";
|
|
12
|
+
import { LinearClient } from "../integrations/linear/client.js";
|
|
13
|
+
import { LinearAuthManager } from "../integrations/linear/auth.js";
|
|
12
14
|
const SAFE_ACTION_PATTERNS = [
|
|
13
15
|
// Git/GitHub CLI commands (limited to safe operations)
|
|
14
16
|
{ pattern: /^gh pr (view|list|status|checks) (\d+)$/ },
|
|
@@ -20,8 +22,20 @@ const SAFE_ACTION_PATTERNS = [
|
|
|
20
22
|
{ pattern: /^npm (test|run build)$/ },
|
|
21
23
|
// StackMemory commands
|
|
22
24
|
{ pattern: /^stackmemory (status|notify check|context list)$/ },
|
|
25
|
+
// Task start with optional --assign-me flag (Linear task ID is UUID format)
|
|
26
|
+
{
|
|
27
|
+
pattern: /^stackmemory task start ([a-f0-9-]{36})( --assign-me)?$/
|
|
28
|
+
},
|
|
29
|
+
// Git commands
|
|
30
|
+
{ pattern: /^git (status|diff|log|branch)( --[a-z-]+)*$/ },
|
|
31
|
+
{ pattern: /^git add -A && git commit$/ },
|
|
32
|
+
{ pattern: /^gh pr create --fill$/ },
|
|
33
|
+
// Custom aliases (cwm = claude worktree merge)
|
|
34
|
+
{ pattern: /^cwm$/ },
|
|
23
35
|
// Simple echo/confirmation (no variables)
|
|
24
|
-
{
|
|
36
|
+
{
|
|
37
|
+
pattern: /^echo "?(Done|OK|Confirmed|Acknowledged|Great work! Time for a coffee break\.)"?$/
|
|
38
|
+
}
|
|
25
39
|
];
|
|
26
40
|
function isActionAllowed(action) {
|
|
27
41
|
const trimmed = action.trim();
|
|
@@ -73,7 +87,60 @@ function queueAction(promptId, response, action) {
|
|
|
73
87
|
saveActionQueue(queue);
|
|
74
88
|
return id;
|
|
75
89
|
}
|
|
76
|
-
function
|
|
90
|
+
function getLinearClient() {
|
|
91
|
+
const apiKey = process.env["LINEAR_API_KEY"];
|
|
92
|
+
if (apiKey) {
|
|
93
|
+
return new LinearClient({ apiKey });
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const authManager = new LinearAuthManager();
|
|
97
|
+
const tokens = authManager.loadTokens();
|
|
98
|
+
if (tokens?.accessToken) {
|
|
99
|
+
return new LinearClient({ accessToken: tokens.accessToken });
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
async function handleSpecialAction(action) {
|
|
106
|
+
const taskStartMatch = action.match(
|
|
107
|
+
/^stackmemory task start ([a-f0-9-]{36})( --assign-me)?$/
|
|
108
|
+
);
|
|
109
|
+
if (taskStartMatch) {
|
|
110
|
+
const issueId = taskStartMatch[1];
|
|
111
|
+
const client = getLinearClient();
|
|
112
|
+
if (!client) {
|
|
113
|
+
return {
|
|
114
|
+
handled: true,
|
|
115
|
+
success: false,
|
|
116
|
+
error: "Linear not configured. Set LINEAR_API_KEY or run stackmemory linear setup."
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const result = await client.startIssue(issueId);
|
|
121
|
+
if (result.success && result.issue) {
|
|
122
|
+
return {
|
|
123
|
+
handled: true,
|
|
124
|
+
success: true,
|
|
125
|
+
output: `Started: ${result.issue.identifier} - ${result.issue.title}`
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
handled: true,
|
|
130
|
+
success: false,
|
|
131
|
+
error: result.error || "Failed to start issue"
|
|
132
|
+
};
|
|
133
|
+
} catch (err) {
|
|
134
|
+
return {
|
|
135
|
+
handled: true,
|
|
136
|
+
success: false,
|
|
137
|
+
error: err instanceof Error ? err.message : "Unknown error"
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { handled: false };
|
|
142
|
+
}
|
|
143
|
+
async function executeActionSafe(action, _response) {
|
|
77
144
|
if (!isActionAllowed(action)) {
|
|
78
145
|
console.error(`[sms-action] Action not in allowlist: ${action}`);
|
|
79
146
|
return {
|
|
@@ -81,6 +148,14 @@ function executeActionSafe(action, _response) {
|
|
|
81
148
|
error: `Action not allowed. Only pre-approved commands can be executed via SMS.`
|
|
82
149
|
};
|
|
83
150
|
}
|
|
151
|
+
const specialResult = await handleSpecialAction(action);
|
|
152
|
+
if (specialResult.handled) {
|
|
153
|
+
return {
|
|
154
|
+
success: specialResult.success || false,
|
|
155
|
+
output: specialResult.output,
|
|
156
|
+
error: specialResult.error
|
|
157
|
+
};
|
|
158
|
+
}
|
|
84
159
|
try {
|
|
85
160
|
console.log(`[sms-action] Executing safe action: ${action}`);
|
|
86
161
|
const parts = action.split(" ");
|
|
@@ -121,30 +196,22 @@ function markActionCompleted(id, result, error) {
|
|
|
121
196
|
saveActionQueue(queue);
|
|
122
197
|
}
|
|
123
198
|
}
|
|
124
|
-
function executeAction(action) {
|
|
199
|
+
async function executeAction(action) {
|
|
125
200
|
markActionRunning(action.id);
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
// 1 minute timeout
|
|
132
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
133
|
-
});
|
|
134
|
-
markActionCompleted(action.id, output);
|
|
135
|
-
return { success: true, output };
|
|
136
|
-
} catch (err) {
|
|
137
|
-
const error = err instanceof Error ? err.message : String(err);
|
|
138
|
-
markActionCompleted(action.id, void 0, error);
|
|
139
|
-
return { success: false, error };
|
|
201
|
+
const result = await executeActionSafe(action.action, action.response);
|
|
202
|
+
if (result.success) {
|
|
203
|
+
markActionCompleted(action.id, result.output);
|
|
204
|
+
} else {
|
|
205
|
+
markActionCompleted(action.id, void 0, result.error);
|
|
140
206
|
}
|
|
207
|
+
return result;
|
|
141
208
|
}
|
|
142
|
-
function processAllPendingActions() {
|
|
209
|
+
async function processAllPendingActions() {
|
|
143
210
|
const pending = getPendingActions();
|
|
144
211
|
let succeeded = 0;
|
|
145
212
|
let failed = 0;
|
|
146
213
|
for (const action of pending) {
|
|
147
|
-
const result = executeAction(action);
|
|
214
|
+
const result = await executeAction(action);
|
|
148
215
|
if (result.success) {
|
|
149
216
|
succeeded++;
|
|
150
217
|
} else {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/hooks/sms-action-runner.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * SMS Action Runner - Executes actions based on SMS responses\n * Bridges SMS responses to Claude Code actions\n *\n * Security: Uses allowlist-based action execution to prevent command injection\n */\n\nimport { existsSync, readFileSync } from 'fs';\nimport { join } from 'path';\nimport { homedir } from 'os';\nimport { execSync, execFileSync } from 'child_process';\nimport { randomBytes } from 'crypto';\nimport { writeFileSecure, ensureSecureDir } from './secure-fs.js';\nimport { ActionQueueSchema, parseConfigSafe } from './schemas.js';\n\n// Allowlist of safe action patterns\nconst SAFE_ACTION_PATTERNS: Array<{\n pattern: RegExp;\n validate?: (match: RegExpMatchArray) => boolean;\n}> = [\n // Git/GitHub CLI commands (limited to safe operations)\n { pattern: /^gh pr (view|list|status|checks) (\\d+)$/ },\n { pattern: /^gh pr review (\\d+) --approve$/ },\n { pattern: /^gh pr merge (\\d+) --squash$/ },\n { pattern: /^gh issue (view|list) (\\d+)?$/ },\n\n // NPM commands (limited to safe operations)\n { pattern: /^npm run (build|test|lint|lint:fix|test:run)$/ },\n { pattern: /^npm (test|run build)$/ },\n\n // StackMemory commands\n { pattern: /^stackmemory (status|notify check|context list)$/ },\n\n // Simple echo/confirmation (no variables)\n { pattern: /^echo \"?(Done|OK|Confirmed|Acknowledged)\"?$/ },\n];\n\n/**\n * Check if an action is in the allowlist\n */\nfunction isActionAllowed(action: string): boolean {\n const trimmed = action.trim();\n return SAFE_ACTION_PATTERNS.some(({ pattern, validate }) => {\n const match = trimmed.match(pattern);\n if (!match) return false;\n if (validate && !validate(match)) return false;\n return true;\n });\n}\n\nexport interface PendingAction {\n id: string;\n promptId: string;\n response: string;\n action: string;\n timestamp: string;\n status: 'pending' | 'running' | 'completed' | 'failed';\n result?: string;\n error?: string;\n}\n\nexport interface ActionQueue {\n actions: PendingAction[];\n lastChecked: string;\n}\n\nconst QUEUE_PATH = join(homedir(), '.stackmemory', 'sms-action-queue.json');\n\nconst DEFAULT_QUEUE: ActionQueue = {\n actions: [],\n lastChecked: new Date().toISOString(),\n};\n\nexport function loadActionQueue(): ActionQueue {\n try {\n if (existsSync(QUEUE_PATH)) {\n const data = JSON.parse(readFileSync(QUEUE_PATH, 'utf8'));\n return parseConfigSafe(\n ActionQueueSchema,\n data,\n DEFAULT_QUEUE,\n 'action-queue'\n );\n }\n } catch {\n // Use defaults\n }\n return { ...DEFAULT_QUEUE, lastChecked: new Date().toISOString() };\n}\n\nexport function saveActionQueue(queue: ActionQueue): void {\n try {\n ensureSecureDir(join(homedir(), '.stackmemory'));\n writeFileSecure(QUEUE_PATH, JSON.stringify(queue, null, 2));\n } catch {\n // Silently fail\n }\n}\n\nexport function queueAction(\n promptId: string,\n response: string,\n action: string\n): string {\n const queue = loadActionQueue();\n // Use cryptographically secure random ID\n const id = randomBytes(8).toString('hex');\n\n queue.actions.push({\n id,\n promptId,\n response,\n action,\n timestamp: new Date().toISOString(),\n status: 'pending',\n });\n\n saveActionQueue(queue);\n return id;\n}\n\n/**\n * Execute an action safely using allowlist validation\n * This prevents command injection by only allowing pre-approved commands\n */\nexport function executeActionSafe(\n action: string,\n _response: string\n): { success: boolean; output?: string; error?: string } {\n // Check if action is in allowlist\n if (!isActionAllowed(action)) {\n console.error(`[sms-action] Action not in allowlist: ${action}`);\n return {\n success: false,\n error: `Action not allowed. Only pre-approved commands can be executed via SMS.`,\n };\n }\n\n try {\n console.log(`[sms-action] Executing safe action: ${action}`);\n\n // Parse the action into command and args\n const parts = action.split(' ');\n const cmd = parts[0];\n const args = parts.slice(1);\n\n // Use execFileSync for commands without shell interpretation\n // This prevents shell injection even if the allowlist is somehow bypassed\n const output = execFileSync(cmd, args, {\n encoding: 'utf8',\n timeout: 60000,\n stdio: ['pipe', 'pipe', 'pipe'],\n shell: false, // Explicitly disable shell\n });\n\n return { success: true, output };\n } catch (err) {\n const error = err instanceof Error ? err.message : String(err);\n return { success: false, error };\n }\n}\n\nexport function getPendingActions(): PendingAction[] {\n const queue = loadActionQueue();\n return queue.actions.filter((a) => a.status === 'pending');\n}\n\nexport function markActionRunning(id: string): void {\n const queue = loadActionQueue();\n const action = queue.actions.find((a) => a.id === id);\n if (action) {\n action.status = 'running';\n saveActionQueue(queue);\n }\n}\n\nexport function markActionCompleted(\n id: string,\n result?: string,\n error?: string\n): void {\n const queue = loadActionQueue();\n const action = queue.actions.find((a) => a.id === id);\n if (action) {\n action.status = error ? 'failed' : 'completed';\n action.result = result;\n action.error = error;\n saveActionQueue(queue);\n }\n}\n\nexport function executeAction(action: PendingAction): {\n success: boolean;\n output?: string;\n error?: string;\n} {\n markActionRunning(action.id);\n\n try {\n console.log(`[sms-action] Executing: ${action.action}`);\n\n // Execute the action\n const output = execSync(action.action, {\n encoding: 'utf8',\n timeout: 60000, // 1 minute timeout\n stdio: ['pipe', 'pipe', 'pipe'],\n });\n\n markActionCompleted(action.id, output);\n return { success: true, output };\n } catch (err) {\n const error = err instanceof Error ? err.message : String(err);\n markActionCompleted(action.id, undefined, error);\n return { success: false, error };\n }\n}\n\nexport function processAllPendingActions(): {\n processed: number;\n succeeded: number;\n failed: number;\n} {\n const pending = getPendingActions();\n let succeeded = 0;\n let failed = 0;\n\n for (const action of pending) {\n const result = executeAction(action);\n if (result.success) {\n succeeded++;\n } else {\n failed++;\n }\n }\n\n return { processed: pending.length, succeeded, failed };\n}\n\n// Clean up old completed actions (keep last 50)\nexport function cleanupOldActions(): number {\n const queue = loadActionQueue();\n const completed = queue.actions.filter(\n (a) => a.status === 'completed' || a.status === 'failed'\n );\n\n if (completed.length > 50) {\n const toRemove = completed.slice(0, completed.length - 50);\n queue.actions = queue.actions.filter(\n (a) => !toRemove.find((r) => r.id === a.id)\n );\n saveActionQueue(queue);\n return toRemove.length;\n }\n\n return 0;\n}\n\n/**\n * Action Templates - Common actions for SMS responses\n *\n * SECURITY NOTE: These templates return command strings that must be\n * validated against SAFE_ACTION_PATTERNS before execution.\n * Templates that accept user input are removed to prevent injection.\n */\nexport const ACTION_TEMPLATES = {\n // Git/PR actions (PR numbers must be validated as integers)\n approvePR: (prNumber: string) => {\n // Validate PR number is numeric only\n if (!/^\\d+$/.test(prNumber)) {\n throw new Error('Invalid PR number');\n }\n return `gh pr review ${prNumber} --approve`;\n },\n mergePR: (prNumber: string) => {\n if (!/^\\d+$/.test(prNumber)) {\n throw new Error('Invalid PR number');\n }\n return `gh pr merge ${prNumber} --squash`;\n },\n viewPR: (prNumber: string) => {\n if (!/^\\d+$/.test(prNumber)) {\n throw new Error('Invalid PR number');\n }\n return `gh pr view ${prNumber}`;\n },\n\n // Build actions (no user input)\n rebuild: () => `npm run build`,\n retest: () => `npm run test:run`,\n lint: () => `npm run lint:fix`,\n\n // Status actions (no user input)\n status: () => `stackmemory status`,\n checkNotifications: () => `stackmemory notify check`,\n\n // REMOVED for security - these templates allowed arbitrary user input:\n // - requestChanges (allowed arbitrary message)\n // - closePR (could be used maliciously)\n // - deploy/rollback (too dangerous for SMS)\n // - verifyDeployment (allowed arbitrary URL)\n // - notifySlack (allowed arbitrary message - command injection)\n // - notifyTeam (allowed arbitrary message - command injection)\n};\n\n/**\n * Create action string from template\n */\nexport function createAction(\n template: keyof typeof ACTION_TEMPLATES,\n ...args: string[]\n): string {\n const fn = ACTION_TEMPLATES[template];\n if (typeof fn === 'function') {\n return (fn as (...args: string[]) => string)(...args);\n }\n return fn;\n}\n\n/**\n * Watch for new actions and execute them\n */\nexport function startActionWatcher(intervalMs: number = 5000): NodeJS.Timeout {\n console.log(\n `[sms-action] Starting action watcher (interval: ${intervalMs}ms)`\n );\n\n return setInterval(() => {\n const pending = getPendingActions();\n if (pending.length > 0) {\n console.log(`[sms-action] Found ${pending.length} pending action(s)`);\n processAllPendingActions();\n }\n }, intervalMs);\n}\n\n/**\n * Integration with SMS webhook - queue action when response received\n */\nexport function handleSMSResponse(\n promptId: string,\n response: string,\n action?: string\n): void {\n if (action) {\n const actionId = queueAction(promptId, response, action);\n console.log(`[sms-action] Queued action ${actionId}: ${action}`);\n }\n}\n"],
|
|
5
|
-
"mappings": ";;;;AAOA,SAAS,YAAY,oBAAoB;AACzC,SAAS,YAAY;AACrB,SAAS,eAAe;AACxB,SAAS,
|
|
4
|
+
"sourcesContent": ["/**\n * SMS Action Runner - Executes actions based on SMS responses\n * Bridges SMS responses to Claude Code actions\n *\n * Security: Uses allowlist-based action execution to prevent command injection\n */\n\nimport { existsSync, readFileSync } from 'fs';\nimport { join } from 'path';\nimport { homedir } from 'os';\nimport { execFileSync } from 'child_process';\nimport { randomBytes } from 'crypto';\nimport { writeFileSecure, ensureSecureDir } from './secure-fs.js';\nimport { ActionQueueSchema, parseConfigSafe } from './schemas.js';\nimport { LinearClient } from '../integrations/linear/client.js';\nimport { LinearAuthManager } from '../integrations/linear/auth.js';\n\n// Allowlist of safe action patterns\nconst SAFE_ACTION_PATTERNS: Array<{\n pattern: RegExp;\n validate?: (match: RegExpMatchArray) => boolean;\n}> = [\n // Git/GitHub CLI commands (limited to safe operations)\n { pattern: /^gh pr (view|list|status|checks) (\\d+)$/ },\n { pattern: /^gh pr review (\\d+) --approve$/ },\n { pattern: /^gh pr merge (\\d+) --squash$/ },\n { pattern: /^gh issue (view|list) (\\d+)?$/ },\n\n // NPM commands (limited to safe operations)\n { pattern: /^npm run (build|test|lint|lint:fix|test:run)$/ },\n { pattern: /^npm (test|run build)$/ },\n\n // StackMemory commands\n { pattern: /^stackmemory (status|notify check|context list)$/ },\n // Task start with optional --assign-me flag (Linear task ID is UUID format)\n {\n pattern: /^stackmemory task start ([a-f0-9-]{36})( --assign-me)?$/,\n },\n\n // Git commands\n { pattern: /^git (status|diff|log|branch)( --[a-z-]+)*$/ },\n { pattern: /^git add -A && git commit$/ },\n { pattern: /^gh pr create --fill$/ },\n\n // Custom aliases (cwm = claude worktree merge)\n { pattern: /^cwm$/ },\n\n // Simple echo/confirmation (no variables)\n {\n pattern:\n /^echo \"?(Done|OK|Confirmed|Acknowledged|Great work! Time for a coffee break\\.)\"?$/,\n },\n];\n\n/**\n * Check if an action is in the allowlist\n */\nfunction isActionAllowed(action: string): boolean {\n const trimmed = action.trim();\n return SAFE_ACTION_PATTERNS.some(({ pattern, validate }) => {\n const match = trimmed.match(pattern);\n if (!match) return false;\n if (validate && !validate(match)) return false;\n return true;\n });\n}\n\nexport interface PendingAction {\n id: string;\n promptId: string;\n response: string;\n action: string;\n timestamp: string;\n status: 'pending' | 'running' | 'completed' | 'failed';\n result?: string;\n error?: string;\n}\n\nexport interface ActionQueue {\n actions: PendingAction[];\n lastChecked: string;\n}\n\nconst QUEUE_PATH = join(homedir(), '.stackmemory', 'sms-action-queue.json');\n\nconst DEFAULT_QUEUE: ActionQueue = {\n actions: [],\n lastChecked: new Date().toISOString(),\n};\n\nexport function loadActionQueue(): ActionQueue {\n try {\n if (existsSync(QUEUE_PATH)) {\n const data = JSON.parse(readFileSync(QUEUE_PATH, 'utf8'));\n return parseConfigSafe(\n ActionQueueSchema,\n data,\n DEFAULT_QUEUE,\n 'action-queue'\n );\n }\n } catch {\n // Use defaults\n }\n return { ...DEFAULT_QUEUE, lastChecked: new Date().toISOString() };\n}\n\nexport function saveActionQueue(queue: ActionQueue): void {\n try {\n ensureSecureDir(join(homedir(), '.stackmemory'));\n writeFileSecure(QUEUE_PATH, JSON.stringify(queue, null, 2));\n } catch {\n // Silently fail\n }\n}\n\nexport function queueAction(\n promptId: string,\n response: string,\n action: string\n): string {\n const queue = loadActionQueue();\n // Use cryptographically secure random ID\n const id = randomBytes(8).toString('hex');\n\n queue.actions.push({\n id,\n promptId,\n response,\n action,\n timestamp: new Date().toISOString(),\n status: 'pending',\n });\n\n saveActionQueue(queue);\n return id;\n}\n\n/**\n * Get Linear client if available\n */\nfunction getLinearClient(): LinearClient | null {\n const apiKey = process.env['LINEAR_API_KEY'];\n if (apiKey) {\n return new LinearClient({ apiKey });\n }\n\n try {\n const authManager = new LinearAuthManager();\n const tokens = authManager.loadTokens();\n if (tokens?.accessToken) {\n return new LinearClient({ accessToken: tokens.accessToken });\n }\n } catch {\n // Auth not available\n }\n\n return null;\n}\n\n/**\n * Handle special actions that require API calls instead of shell commands\n */\nasync function handleSpecialAction(action: string): Promise<{\n handled: boolean;\n success?: boolean;\n output?: string;\n error?: string;\n}> {\n // Handle stackmemory task start command\n const taskStartMatch = action.match(\n /^stackmemory task start ([a-f0-9-]{36})( --assign-me)?$/\n );\n if (taskStartMatch) {\n const issueId = taskStartMatch[1];\n const client = getLinearClient();\n\n if (!client) {\n return {\n handled: true,\n success: false,\n error:\n 'Linear not configured. Set LINEAR_API_KEY or run stackmemory linear setup.',\n };\n }\n\n try {\n const result = await client.startIssue(issueId);\n if (result.success && result.issue) {\n return {\n handled: true,\n success: true,\n output: `Started: ${result.issue.identifier} - ${result.issue.title}`,\n };\n }\n return {\n handled: true,\n success: false,\n error: result.error || 'Failed to start issue',\n };\n } catch (err) {\n return {\n handled: true,\n success: false,\n error: err instanceof Error ? err.message : 'Unknown error',\n };\n }\n }\n\n return { handled: false };\n}\n\n/**\n * Execute an action safely using allowlist validation\n * This prevents command injection by only allowing pre-approved commands\n */\nexport async function executeActionSafe(\n action: string,\n _response: string\n): Promise<{ success: boolean; output?: string; error?: string }> {\n // Check if action is in allowlist\n if (!isActionAllowed(action)) {\n console.error(`[sms-action] Action not in allowlist: ${action}`);\n return {\n success: false,\n error: `Action not allowed. Only pre-approved commands can be executed via SMS.`,\n };\n }\n\n // Check for special actions that need API calls\n const specialResult = await handleSpecialAction(action);\n if (specialResult.handled) {\n return {\n success: specialResult.success || false,\n output: specialResult.output,\n error: specialResult.error,\n };\n }\n\n try {\n console.log(`[sms-action] Executing safe action: ${action}`);\n\n // Parse the action into command and args\n const parts = action.split(' ');\n const cmd = parts[0];\n const args = parts.slice(1);\n\n // Use execFileSync for commands without shell interpretation\n // This prevents shell injection even if the allowlist is somehow bypassed\n const output = execFileSync(cmd, args, {\n encoding: 'utf8',\n timeout: 60000,\n stdio: ['pipe', 'pipe', 'pipe'],\n shell: false, // Explicitly disable shell\n });\n\n return { success: true, output };\n } catch (err) {\n const error = err instanceof Error ? err.message : String(err);\n return { success: false, error };\n }\n}\n\nexport function getPendingActions(): PendingAction[] {\n const queue = loadActionQueue();\n return queue.actions.filter((a) => a.status === 'pending');\n}\n\nexport function markActionRunning(id: string): void {\n const queue = loadActionQueue();\n const action = queue.actions.find((a) => a.id === id);\n if (action) {\n action.status = 'running';\n saveActionQueue(queue);\n }\n}\n\nexport function markActionCompleted(\n id: string,\n result?: string,\n error?: string\n): void {\n const queue = loadActionQueue();\n const action = queue.actions.find((a) => a.id === id);\n if (action) {\n action.status = error ? 'failed' : 'completed';\n action.result = result;\n action.error = error;\n saveActionQueue(queue);\n }\n}\n\nexport async function executeAction(action: PendingAction): Promise<{\n success: boolean;\n output?: string;\n error?: string;\n}> {\n markActionRunning(action.id);\n\n // Use the safe execution path to prevent command injection\n const result = await executeActionSafe(action.action, action.response);\n\n if (result.success) {\n markActionCompleted(action.id, result.output);\n } else {\n markActionCompleted(action.id, undefined, result.error);\n }\n\n return result;\n}\n\nexport async function processAllPendingActions(): Promise<{\n processed: number;\n succeeded: number;\n failed: number;\n}> {\n const pending = getPendingActions();\n let succeeded = 0;\n let failed = 0;\n\n for (const action of pending) {\n const result = await executeAction(action);\n if (result.success) {\n succeeded++;\n } else {\n failed++;\n }\n }\n\n return { processed: pending.length, succeeded, failed };\n}\n\n// Clean up old completed actions (keep last 50)\nexport function cleanupOldActions(): number {\n const queue = loadActionQueue();\n const completed = queue.actions.filter(\n (a) => a.status === 'completed' || a.status === 'failed'\n );\n\n if (completed.length > 50) {\n const toRemove = completed.slice(0, completed.length - 50);\n queue.actions = queue.actions.filter(\n (a) => !toRemove.find((r) => r.id === a.id)\n );\n saveActionQueue(queue);\n return toRemove.length;\n }\n\n return 0;\n}\n\n/**\n * Action Templates - Common actions for SMS responses\n *\n * SECURITY NOTE: These templates return command strings that must be\n * validated against SAFE_ACTION_PATTERNS before execution.\n * Templates that accept user input are removed to prevent injection.\n */\nexport const ACTION_TEMPLATES = {\n // Git/PR actions (PR numbers must be validated as integers)\n approvePR: (prNumber: string) => {\n // Validate PR number is numeric only\n if (!/^\\d+$/.test(prNumber)) {\n throw new Error('Invalid PR number');\n }\n return `gh pr review ${prNumber} --approve`;\n },\n mergePR: (prNumber: string) => {\n if (!/^\\d+$/.test(prNumber)) {\n throw new Error('Invalid PR number');\n }\n return `gh pr merge ${prNumber} --squash`;\n },\n viewPR: (prNumber: string) => {\n if (!/^\\d+$/.test(prNumber)) {\n throw new Error('Invalid PR number');\n }\n return `gh pr view ${prNumber}`;\n },\n\n // Build actions (no user input)\n rebuild: () => `npm run build`,\n retest: () => `npm run test:run`,\n lint: () => `npm run lint:fix`,\n\n // Status actions (no user input)\n status: () => `stackmemory status`,\n checkNotifications: () => `stackmemory notify check`,\n\n // REMOVED for security - these templates allowed arbitrary user input:\n // - requestChanges (allowed arbitrary message)\n // - closePR (could be used maliciously)\n // - deploy/rollback (too dangerous for SMS)\n // - verifyDeployment (allowed arbitrary URL)\n // - notifySlack (allowed arbitrary message - command injection)\n // - notifyTeam (allowed arbitrary message - command injection)\n};\n\n/**\n * Create action string from template\n */\nexport function createAction(\n template: keyof typeof ACTION_TEMPLATES,\n ...args: string[]\n): string {\n const fn = ACTION_TEMPLATES[template];\n if (typeof fn === 'function') {\n return (fn as (...args: string[]) => string)(...args);\n }\n return fn;\n}\n\n/**\n * Watch for new actions and execute them\n */\nexport function startActionWatcher(intervalMs: number = 5000): NodeJS.Timeout {\n console.log(\n `[sms-action] Starting action watcher (interval: ${intervalMs}ms)`\n );\n\n return setInterval(() => {\n const pending = getPendingActions();\n if (pending.length > 0) {\n console.log(`[sms-action] Found ${pending.length} pending action(s)`);\n processAllPendingActions();\n }\n }, intervalMs);\n}\n\n/**\n * Integration with SMS webhook - queue action when response received\n */\nexport function handleSMSResponse(\n promptId: string,\n response: string,\n action?: string\n): void {\n if (action) {\n const actionId = queueAction(promptId, response, action);\n console.log(`[sms-action] Queued action ${actionId}: ${action}`);\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -93,7 +93,7 @@ function storeLatestResponse(promptId, response, action) {
|
|
|
93
93
|
})
|
|
94
94
|
);
|
|
95
95
|
}
|
|
96
|
-
function handleSMSWebhook(payload) {
|
|
96
|
+
async function handleSMSWebhook(payload) {
|
|
97
97
|
const { From, Body } = payload;
|
|
98
98
|
if (Body && Body.length > MAX_SMS_BODY_LENGTH) {
|
|
99
99
|
console.log(`[sms-webhook] Body too long: ${Body.length} chars`);
|
|
@@ -121,7 +121,7 @@ function handleSMSWebhook(payload) {
|
|
|
121
121
|
triggerResponseNotification(result.response || Body);
|
|
122
122
|
if (result.action) {
|
|
123
123
|
console.log(`[sms-webhook] Executing action: ${result.action}`);
|
|
124
|
-
const actionResult = executeActionSafe(
|
|
124
|
+
const actionResult = await executeActionSafe(
|
|
125
125
|
result.action,
|
|
126
126
|
result.response || Body
|
|
127
127
|
);
|
|
@@ -243,7 +243,7 @@ function startWebhookServer(port = 3456) {
|
|
|
243
243
|
req.destroy();
|
|
244
244
|
}
|
|
245
245
|
});
|
|
246
|
-
req.on("end", () => {
|
|
246
|
+
req.on("end", async () => {
|
|
247
247
|
if (bodyTooLarge) {
|
|
248
248
|
res.writeHead(413, { "Content-Type": "text/xml" });
|
|
249
249
|
res.end(twimlResponse("Request too large"));
|
|
@@ -266,7 +266,7 @@ function startWebhookServer(port = 3456) {
|
|
|
266
266
|
res.end(twimlResponse("Unauthorized"));
|
|
267
267
|
return;
|
|
268
268
|
}
|
|
269
|
-
const result = handleSMSWebhook(payload);
|
|
269
|
+
const result = await handleSMSWebhook(payload);
|
|
270
270
|
res.writeHead(200, { "Content-Type": "text/xml" });
|
|
271
271
|
res.end(twimlResponse(result.response));
|
|
272
272
|
} catch (err) {
|
|
@@ -348,8 +348,8 @@ function startWebhookServer(port = 3456) {
|
|
|
348
348
|
);
|
|
349
349
|
});
|
|
350
350
|
}
|
|
351
|
-
function smsWebhookMiddleware(req, res) {
|
|
352
|
-
const result = handleSMSWebhook(req.body);
|
|
351
|
+
async function smsWebhookMiddleware(req, res) {
|
|
352
|
+
const result = await handleSMSWebhook(req.body);
|
|
353
353
|
res.type("text/xml");
|
|
354
354
|
res.send(twimlResponse(result.response));
|
|
355
355
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/hooks/sms-webhook.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * SMS Webhook Handler for receiving Twilio responses\n * Can run as standalone server or integrate with existing Express app\n *\n * Security features:\n * - Twilio signature verification\n * - Rate limiting per IP\n * - Body size limits\n * - Content-type validation\n * - Safe action execution (no shell injection)\n */\n\nimport { createServer, IncomingMessage, ServerResponse } from 'http';\nimport { parse as parseUrl } from 'url';\nimport { existsSync, readFileSync } from 'fs';\nimport { join } from 'path';\nimport { homedir } from 'os';\nimport { createHmac } from 'crypto';\nimport { execFileSync } from 'child_process';\nimport {\n processIncomingResponse,\n loadSMSConfig,\n cleanupExpiredPrompts,\n} from './sms-notify.js';\nimport {\n queueAction,\n executeActionSafe,\n cleanupOldActions,\n} from './sms-action-runner.js';\nimport { writeFileSecure, ensureSecureDir } from './secure-fs.js';\nimport {\n logWebhookRequest,\n logRateLimit,\n logSignatureInvalid,\n logBodyTooLarge,\n logContentTypeInvalid,\n logActionAllowed,\n logActionBlocked,\n logCleanup,\n} from './security-logger.js';\n\n// Cleanup interval (5 minutes)\nconst CLEANUP_INTERVAL_MS = 5 * 60 * 1000;\n\n// Input validation constants\nconst MAX_SMS_BODY_LENGTH = 1000;\nconst MAX_PHONE_LENGTH = 20;\n\n// Security constants\nconst MAX_BODY_SIZE = 50 * 1024; // 50KB max body\nconst RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute\nconst RATE_LIMIT_MAX_REQUESTS = 30; // 30 requests per minute per IP\n\n// Rate limiting store (in production, use Redis)\nconst rateLimitStore = new Map<string, { count: number; resetTime: number }>();\n\nfunction checkRateLimit(ip: string): boolean {\n const now = Date.now();\n const record = rateLimitStore.get(ip);\n\n if (!record || now > record.resetTime) {\n rateLimitStore.set(ip, { count: 1, resetTime: now + RATE_LIMIT_WINDOW_MS });\n return true;\n }\n\n if (record.count >= RATE_LIMIT_MAX_REQUESTS) {\n return false;\n }\n\n record.count++;\n return true;\n}\n\n// Twilio signature verification\nfunction verifyTwilioSignature(\n url: string,\n params: Record<string, string>,\n signature: string\n): boolean {\n const authToken = process.env['TWILIO_AUTH_TOKEN'];\n if (!authToken) {\n console.warn(\n '[sms-webhook] TWILIO_AUTH_TOKEN not set, skipping signature verification'\n );\n return true; // Allow in development, but log warning\n }\n\n // Build the data string (URL + sorted params)\n const sortedKeys = Object.keys(params).sort();\n let data = url;\n for (const key of sortedKeys) {\n data += key + params[key];\n }\n\n // Calculate expected signature\n const hmac = createHmac('sha1', authToken);\n hmac.update(data);\n const expectedSignature = hmac.digest('base64');\n\n return signature === expectedSignature;\n}\n\ninterface TwilioWebhookPayload {\n From: string;\n To: string;\n Body: string;\n MessageSid: string;\n}\n\nfunction parseFormData(body: string): Record<string, string> {\n const params = new URLSearchParams(body);\n const result: Record<string, string> = {};\n params.forEach((value, key) => {\n result[key] = value;\n });\n return result;\n}\n\n// Store response for Claude hook to pick up\nfunction storeLatestResponse(\n promptId: string,\n response: string,\n action?: string\n): void {\n ensureSecureDir(join(homedir(), '.stackmemory'));\n const responsePath = join(\n homedir(),\n '.stackmemory',\n 'sms-latest-response.json'\n );\n writeFileSecure(\n responsePath,\n JSON.stringify({\n promptId,\n response,\n action,\n timestamp: new Date().toISOString(),\n })\n );\n}\n\nexport function handleSMSWebhook(payload: TwilioWebhookPayload): {\n response: string;\n action?: string;\n queued?: boolean;\n} {\n const { From, Body } = payload;\n\n // Input length validation\n if (Body && Body.length > MAX_SMS_BODY_LENGTH) {\n console.log(`[sms-webhook] Body too long: ${Body.length} chars`);\n return { response: 'Message too long. Max 1000 characters.' };\n }\n\n if (From && From.length > MAX_PHONE_LENGTH) {\n console.log(`[sms-webhook] Invalid phone number length`);\n return { response: 'Invalid phone number.' };\n }\n\n console.log(`[sms-webhook] Received from ${From}: ${Body}`);\n\n const result = processIncomingResponse(From, Body);\n\n if (!result.matched) {\n if (result.prompt) {\n return {\n response: `Invalid response. Expected: ${result.prompt.options.map((o) => o.key).join(', ')}`,\n };\n }\n return { response: 'No pending prompt found.' };\n }\n\n // Store response for Claude hook\n storeLatestResponse(\n result.prompt?.id || 'unknown',\n result.response || Body,\n result.action\n );\n\n // Trigger notification to alert user/Claude\n triggerResponseNotification(result.response || Body);\n\n // Execute action safely if present (no shell injection)\n if (result.action) {\n console.log(`[sms-webhook] Executing action: ${result.action}`);\n\n const actionResult = executeActionSafe(\n result.action,\n result.response || Body\n );\n\n if (actionResult.success) {\n logActionAllowed('sms-webhook', result.action);\n console.log(\n `[sms-webhook] Action completed: ${(actionResult.output || '').substring(0, 200)}`\n );\n\n return {\n response: `Done! Action executed successfully.`,\n action: result.action,\n queued: false,\n };\n } else {\n logActionBlocked(\n 'sms-webhook',\n result.action,\n actionResult.error || 'unknown'\n );\n console.log(`[sms-webhook] Action failed: ${actionResult.error}`);\n\n // Queue for retry\n queueAction(\n result.prompt?.id || 'unknown',\n result.response || Body,\n result.action\n );\n\n return {\n response: `Action failed, queued for retry: ${(actionResult.error || '').substring(0, 50)}`,\n action: result.action,\n queued: true,\n };\n }\n }\n\n return {\n response: `Received: ${result.response}. Next action will be triggered.`,\n };\n}\n\n// Escape string for AppleScript (prevent injection)\nfunction escapeAppleScript(str: string): string {\n return str\n .replace(/\\\\/g, '\\\\\\\\')\n .replace(/\"/g, '\\\\\"')\n .replace(/\\n/g, '\\\\n')\n .replace(/\\r/g, '\\\\r')\n .substring(0, 200); // Limit length\n}\n\n// Trigger notification when response received\nfunction triggerResponseNotification(response: string): void {\n const safeMessage = escapeAppleScript(`SMS Response: ${response}`);\n\n // macOS notification using execFile (safer than execSync with shell)\n try {\n execFileSync(\n 'osascript',\n [\n '-e',\n `display notification \"${safeMessage}\" with title \"StackMemory\" sound name \"Glass\"`,\n ],\n { stdio: 'ignore', timeout: 5000 }\n );\n } catch {\n // Ignore if not on macOS\n }\n\n // Write signal file for other processes\n try {\n const signalPath = join(homedir(), '.stackmemory', 'sms-signal.txt');\n writeFileSecure(\n signalPath,\n JSON.stringify({\n type: 'sms_response',\n response,\n timestamp: new Date().toISOString(),\n })\n );\n } catch {\n // Ignore\n }\n\n console.log(`\\n*** SMS RESPONSE RECEIVED: \"${response}\" ***`);\n console.log(`*** Run: stackmemory notify run-actions ***\\n`);\n}\n\n// TwiML response helper\nfunction twimlResponse(message: string): string {\n return `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Response>\n <Message>${escapeXml(message)}</Message>\n</Response>`;\n}\n\nfunction escapeXml(str: string): string {\n return str\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n\n// Standalone webhook server\nexport function startWebhookServer(port: number = 3456): void {\n const server = createServer(\n async (req: IncomingMessage, res: ServerResponse) => {\n const url = parseUrl(req.url || '/', true);\n\n // Health check\n if (url.pathname === '/health') {\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ status: 'ok' }));\n return;\n }\n\n // SMS webhook endpoint (incoming messages)\n if (\n (url.pathname === '/sms' ||\n url.pathname === '/sms/incoming' ||\n url.pathname === '/webhook') &&\n req.method === 'POST'\n ) {\n const clientIp = req.socket.remoteAddress || 'unknown';\n\n // Log webhook request\n logWebhookRequest(\n 'sms-webhook',\n req.method || 'POST',\n url.pathname || '/sms',\n clientIp\n );\n\n // Rate limiting\n if (!checkRateLimit(clientIp)) {\n logRateLimit('sms-webhook', clientIp);\n res.writeHead(429, {\n 'Content-Type': 'text/xml',\n 'Retry-After': '60',\n });\n res.end(twimlResponse('Too many requests. Please try again later.'));\n return;\n }\n\n // Content-type validation\n const contentType = req.headers['content-type'] || '';\n if (!contentType.includes('application/x-www-form-urlencoded')) {\n logContentTypeInvalid('sms-webhook', contentType, clientIp);\n res.writeHead(400, { 'Content-Type': 'text/xml' });\n res.end(twimlResponse('Invalid content type'));\n return;\n }\n\n let body = '';\n let bodyTooLarge = false;\n\n req.on('data', (chunk) => {\n body += chunk;\n // Body size limit\n if (body.length > MAX_BODY_SIZE) {\n bodyTooLarge = true;\n logBodyTooLarge('sms-webhook', body.length, clientIp);\n req.destroy();\n }\n });\n\n req.on('end', () => {\n if (bodyTooLarge) {\n res.writeHead(413, { 'Content-Type': 'text/xml' });\n res.end(twimlResponse('Request too large'));\n return;\n }\n\n try {\n const payload = parseFormData(\n body\n ) as unknown as TwilioWebhookPayload;\n\n // Verify Twilio signature\n const twilioSignature = req.headers['x-twilio-signature'] as string;\n const webhookUrl = `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers.host}${req.url}`;\n\n if (\n twilioSignature &&\n !verifyTwilioSignature(\n webhookUrl,\n payload as unknown as Record<string, string>,\n twilioSignature\n )\n ) {\n logSignatureInvalid('sms-webhook', clientIp);\n console.error('[sms-webhook] Invalid Twilio signature');\n res.writeHead(401, { 'Content-Type': 'text/xml' });\n res.end(twimlResponse('Unauthorized'));\n return;\n }\n\n const result = handleSMSWebhook(payload);\n\n res.writeHead(200, { 'Content-Type': 'text/xml' });\n res.end(twimlResponse(result.response));\n } catch (err) {\n console.error('[sms-webhook] Error:', err);\n res.writeHead(500, { 'Content-Type': 'text/xml' });\n res.end(twimlResponse('Error processing message'));\n }\n });\n return;\n }\n\n // Status callback endpoint (delivery status updates)\n if (url.pathname === '/sms/status' && req.method === 'POST') {\n let body = '';\n req.on('data', (chunk) => {\n body += chunk;\n });\n\n req.on('end', () => {\n try {\n const payload = parseFormData(body);\n console.log(\n `[sms-webhook] Status update: ${payload['MessageSid']} -> ${payload['MessageStatus']}`\n );\n\n // Store status for tracking\n const statusPath = join(\n homedir(),\n '.stackmemory',\n 'sms-status.json'\n );\n const statuses: Record<string, string> = existsSync(statusPath)\n ? JSON.parse(readFileSync(statusPath, 'utf8'))\n : {};\n statuses[payload['MessageSid']] = payload['MessageStatus'];\n writeFileSecure(statusPath, JSON.stringify(statuses, null, 2));\n\n res.writeHead(200, { 'Content-Type': 'text/plain' });\n res.end('OK');\n } catch (err) {\n console.error('[sms-webhook] Status error:', err);\n res.writeHead(500);\n res.end('Error');\n }\n });\n return;\n }\n\n // Server status endpoint\n if (url.pathname === '/status') {\n const config = loadSMSConfig();\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(\n JSON.stringify({\n enabled: config.enabled,\n pendingPrompts: config.pendingPrompts.length,\n })\n );\n return;\n }\n\n res.writeHead(404);\n res.end('Not found');\n }\n );\n\n server.listen(port, () => {\n console.log(`[sms-webhook] Server listening on port ${port}`);\n console.log(\n `[sms-webhook] Incoming messages: http://localhost:${port}/sms/incoming`\n );\n console.log(\n `[sms-webhook] Status callback: http://localhost:${port}/sms/status`\n );\n console.log(`[sms-webhook] Configure these URLs in Twilio console`);\n\n // Start timed cleanup of expired prompts and old actions\n setInterval(() => {\n try {\n const expiredPrompts = cleanupExpiredPrompts();\n const oldActions = cleanupOldActions();\n if (expiredPrompts > 0 || oldActions > 0) {\n logCleanup('sms-webhook', expiredPrompts, oldActions);\n console.log(\n `[sms-webhook] Cleanup: ${expiredPrompts} expired prompts, ${oldActions} old actions`\n );\n }\n } catch {\n // Ignore cleanup errors\n }\n }, CLEANUP_INTERVAL_MS);\n console.log(\n `[sms-webhook] Cleanup interval: every ${CLEANUP_INTERVAL_MS / 1000}s`\n );\n });\n}\n\n// Express middleware for integration\nexport function smsWebhookMiddleware(\n req: { body: TwilioWebhookPayload },\n res: { type: (t: string) => void; send: (s: string) => void }\n): void {\n const result = handleSMSWebhook(req.body);\n res.type('text/xml');\n res.send(twimlResponse(result.response));\n}\n\n// CLI entry\nif (process.argv[1]?.endsWith('sms-webhook.js')) {\n const port = parseInt(process.env['SMS_WEBHOOK_PORT'] || '3456', 10);\n startWebhookServer(port);\n}\n"],
|
|
5
|
-
"mappings": ";;;;AAYA,SAAS,oBAAqD;AAC9D,SAAS,SAAS,gBAAgB;AAClC,SAAS,YAAY,oBAAoB;AACzC,SAAS,YAAY;AACrB,SAAS,eAAe;AACxB,SAAS,kBAAkB;AAC3B,SAAS,oBAAoB;AAC7B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,iBAAiB,uBAAuB;AACjD;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAGP,MAAM,sBAAsB,IAAI,KAAK;AAGrC,MAAM,sBAAsB;AAC5B,MAAM,mBAAmB;AAGzB,MAAM,gBAAgB,KAAK;AAC3B,MAAM,uBAAuB,KAAK;AAClC,MAAM,0BAA0B;AAGhC,MAAM,iBAAiB,oBAAI,IAAkD;AAE7E,SAAS,eAAe,IAAqB;AAC3C,QAAM,MAAM,KAAK,IAAI;AACrB,QAAM,SAAS,eAAe,IAAI,EAAE;AAEpC,MAAI,CAAC,UAAU,MAAM,OAAO,WAAW;AACrC,mBAAe,IAAI,IAAI,EAAE,OAAO,GAAG,WAAW,MAAM,qBAAqB,CAAC;AAC1E,WAAO;AAAA,EACT;AAEA,MAAI,OAAO,SAAS,yBAAyB;AAC3C,WAAO;AAAA,EACT;AAEA,SAAO;AACP,SAAO;AACT;AAGA,SAAS,sBACP,KACA,QACA,WACS;AACT,QAAM,YAAY,QAAQ,IAAI,mBAAmB;AACjD,MAAI,CAAC,WAAW;AACd,YAAQ;AAAA,MACN;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAGA,QAAM,aAAa,OAAO,KAAK,MAAM,EAAE,KAAK;AAC5C,MAAI,OAAO;AACX,aAAW,OAAO,YAAY;AAC5B,YAAQ,MAAM,OAAO,GAAG;AAAA,EAC1B;AAGA,QAAM,OAAO,WAAW,QAAQ,SAAS;AACzC,OAAK,OAAO,IAAI;AAChB,QAAM,oBAAoB,KAAK,OAAO,QAAQ;AAE9C,SAAO,cAAc;AACvB;AASA,SAAS,cAAc,MAAsC;AAC3D,QAAM,SAAS,IAAI,gBAAgB,IAAI;AACvC,QAAM,SAAiC,CAAC;AACxC,SAAO,QAAQ,CAAC,OAAO,QAAQ;AAC7B,WAAO,GAAG,IAAI;AAAA,EAChB,CAAC;AACD,SAAO;AACT;AAGA,SAAS,oBACP,UACA,UACA,QACM;AACN,kBAAgB,KAAK,QAAQ,GAAG,cAAc,CAAC;AAC/C,QAAM,eAAe;AAAA,IACnB,QAAQ;AAAA,IACR;AAAA,IACA;AAAA,EACF;AACA;AAAA,IACE;AAAA,IACA,KAAK,UAAU;AAAA,MACb;AAAA,MACA;AAAA,MACA;AAAA,MACA,YAAW,oBAAI,KAAK,GAAE,YAAY;AAAA,IACpC,CAAC;AAAA,EACH;AACF;
|
|
4
|
+
"sourcesContent": ["/**\n * SMS Webhook Handler for receiving Twilio responses\n * Can run as standalone server or integrate with existing Express app\n *\n * Security features:\n * - Twilio signature verification\n * - Rate limiting per IP\n * - Body size limits\n * - Content-type validation\n * - Safe action execution (no shell injection)\n */\n\nimport { createServer, IncomingMessage, ServerResponse } from 'http';\nimport { parse as parseUrl } from 'url';\nimport { existsSync, readFileSync } from 'fs';\nimport { join } from 'path';\nimport { homedir } from 'os';\nimport { createHmac } from 'crypto';\nimport { execFileSync } from 'child_process';\nimport {\n processIncomingResponse,\n loadSMSConfig,\n cleanupExpiredPrompts,\n} from './sms-notify.js';\nimport {\n queueAction,\n executeActionSafe,\n cleanupOldActions,\n} from './sms-action-runner.js';\nimport { writeFileSecure, ensureSecureDir } from './secure-fs.js';\nimport {\n logWebhookRequest,\n logRateLimit,\n logSignatureInvalid,\n logBodyTooLarge,\n logContentTypeInvalid,\n logActionAllowed,\n logActionBlocked,\n logCleanup,\n} from './security-logger.js';\n\n// Cleanup interval (5 minutes)\nconst CLEANUP_INTERVAL_MS = 5 * 60 * 1000;\n\n// Input validation constants\nconst MAX_SMS_BODY_LENGTH = 1000;\nconst MAX_PHONE_LENGTH = 20;\n\n// Security constants\nconst MAX_BODY_SIZE = 50 * 1024; // 50KB max body\nconst RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute\nconst RATE_LIMIT_MAX_REQUESTS = 30; // 30 requests per minute per IP\n\n// Rate limiting store (in production, use Redis)\nconst rateLimitStore = new Map<string, { count: number; resetTime: number }>();\n\nfunction checkRateLimit(ip: string): boolean {\n const now = Date.now();\n const record = rateLimitStore.get(ip);\n\n if (!record || now > record.resetTime) {\n rateLimitStore.set(ip, { count: 1, resetTime: now + RATE_LIMIT_WINDOW_MS });\n return true;\n }\n\n if (record.count >= RATE_LIMIT_MAX_REQUESTS) {\n return false;\n }\n\n record.count++;\n return true;\n}\n\n// Twilio signature verification\nfunction verifyTwilioSignature(\n url: string,\n params: Record<string, string>,\n signature: string\n): boolean {\n const authToken = process.env['TWILIO_AUTH_TOKEN'];\n if (!authToken) {\n console.warn(\n '[sms-webhook] TWILIO_AUTH_TOKEN not set, skipping signature verification'\n );\n return true; // Allow in development, but log warning\n }\n\n // Build the data string (URL + sorted params)\n const sortedKeys = Object.keys(params).sort();\n let data = url;\n for (const key of sortedKeys) {\n data += key + params[key];\n }\n\n // Calculate expected signature\n const hmac = createHmac('sha1', authToken);\n hmac.update(data);\n const expectedSignature = hmac.digest('base64');\n\n return signature === expectedSignature;\n}\n\ninterface TwilioWebhookPayload {\n From: string;\n To: string;\n Body: string;\n MessageSid: string;\n}\n\nfunction parseFormData(body: string): Record<string, string> {\n const params = new URLSearchParams(body);\n const result: Record<string, string> = {};\n params.forEach((value, key) => {\n result[key] = value;\n });\n return result;\n}\n\n// Store response for Claude hook to pick up\nfunction storeLatestResponse(\n promptId: string,\n response: string,\n action?: string\n): void {\n ensureSecureDir(join(homedir(), '.stackmemory'));\n const responsePath = join(\n homedir(),\n '.stackmemory',\n 'sms-latest-response.json'\n );\n writeFileSecure(\n responsePath,\n JSON.stringify({\n promptId,\n response,\n action,\n timestamp: new Date().toISOString(),\n })\n );\n}\n\nexport async function handleSMSWebhook(payload: TwilioWebhookPayload): Promise<{\n response: string;\n action?: string;\n queued?: boolean;\n}> {\n const { From, Body } = payload;\n\n // Input length validation\n if (Body && Body.length > MAX_SMS_BODY_LENGTH) {\n console.log(`[sms-webhook] Body too long: ${Body.length} chars`);\n return { response: 'Message too long. Max 1000 characters.' };\n }\n\n if (From && From.length > MAX_PHONE_LENGTH) {\n console.log(`[sms-webhook] Invalid phone number length`);\n return { response: 'Invalid phone number.' };\n }\n\n console.log(`[sms-webhook] Received from ${From}: ${Body}`);\n\n const result = processIncomingResponse(From, Body);\n\n if (!result.matched) {\n if (result.prompt) {\n return {\n response: `Invalid response. Expected: ${result.prompt.options.map((o) => o.key).join(', ')}`,\n };\n }\n return { response: 'No pending prompt found.' };\n }\n\n // Store response for Claude hook\n storeLatestResponse(\n result.prompt?.id || 'unknown',\n result.response || Body,\n result.action\n );\n\n // Trigger notification to alert user/Claude\n triggerResponseNotification(result.response || Body);\n\n // Execute action safely if present (no shell injection)\n if (result.action) {\n console.log(`[sms-webhook] Executing action: ${result.action}`);\n\n const actionResult = await executeActionSafe(\n result.action,\n result.response || Body\n );\n\n if (actionResult.success) {\n logActionAllowed('sms-webhook', result.action);\n console.log(\n `[sms-webhook] Action completed: ${(actionResult.output || '').substring(0, 200)}`\n );\n\n return {\n response: `Done! Action executed successfully.`,\n action: result.action,\n queued: false,\n };\n } else {\n logActionBlocked(\n 'sms-webhook',\n result.action,\n actionResult.error || 'unknown'\n );\n console.log(`[sms-webhook] Action failed: ${actionResult.error}`);\n\n // Queue for retry\n queueAction(\n result.prompt?.id || 'unknown',\n result.response || Body,\n result.action\n );\n\n return {\n response: `Action failed, queued for retry: ${(actionResult.error || '').substring(0, 50)}`,\n action: result.action,\n queued: true,\n };\n }\n }\n\n return {\n response: `Received: ${result.response}. Next action will be triggered.`,\n };\n}\n\n// Escape string for AppleScript (prevent injection)\nfunction escapeAppleScript(str: string): string {\n return str\n .replace(/\\\\/g, '\\\\\\\\')\n .replace(/\"/g, '\\\\\"')\n .replace(/\\n/g, '\\\\n')\n .replace(/\\r/g, '\\\\r')\n .substring(0, 200); // Limit length\n}\n\n// Trigger notification when response received\nfunction triggerResponseNotification(response: string): void {\n const safeMessage = escapeAppleScript(`SMS Response: ${response}`);\n\n // macOS notification using execFile (safer than execSync with shell)\n try {\n execFileSync(\n 'osascript',\n [\n '-e',\n `display notification \"${safeMessage}\" with title \"StackMemory\" sound name \"Glass\"`,\n ],\n { stdio: 'ignore', timeout: 5000 }\n );\n } catch {\n // Ignore if not on macOS\n }\n\n // Write signal file for other processes\n try {\n const signalPath = join(homedir(), '.stackmemory', 'sms-signal.txt');\n writeFileSecure(\n signalPath,\n JSON.stringify({\n type: 'sms_response',\n response,\n timestamp: new Date().toISOString(),\n })\n );\n } catch {\n // Ignore\n }\n\n console.log(`\\n*** SMS RESPONSE RECEIVED: \"${response}\" ***`);\n console.log(`*** Run: stackmemory notify run-actions ***\\n`);\n}\n\n// TwiML response helper\nfunction twimlResponse(message: string): string {\n return `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Response>\n <Message>${escapeXml(message)}</Message>\n</Response>`;\n}\n\nfunction escapeXml(str: string): string {\n return str\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n\n// Standalone webhook server\nexport function startWebhookServer(port: number = 3456): void {\n const server = createServer(\n async (req: IncomingMessage, res: ServerResponse) => {\n const url = parseUrl(req.url || '/', true);\n\n // Health check\n if (url.pathname === '/health') {\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ status: 'ok' }));\n return;\n }\n\n // SMS webhook endpoint (incoming messages)\n if (\n (url.pathname === '/sms' ||\n url.pathname === '/sms/incoming' ||\n url.pathname === '/webhook') &&\n req.method === 'POST'\n ) {\n const clientIp = req.socket.remoteAddress || 'unknown';\n\n // Log webhook request\n logWebhookRequest(\n 'sms-webhook',\n req.method || 'POST',\n url.pathname || '/sms',\n clientIp\n );\n\n // Rate limiting\n if (!checkRateLimit(clientIp)) {\n logRateLimit('sms-webhook', clientIp);\n res.writeHead(429, {\n 'Content-Type': 'text/xml',\n 'Retry-After': '60',\n });\n res.end(twimlResponse('Too many requests. Please try again later.'));\n return;\n }\n\n // Content-type validation\n const contentType = req.headers['content-type'] || '';\n if (!contentType.includes('application/x-www-form-urlencoded')) {\n logContentTypeInvalid('sms-webhook', contentType, clientIp);\n res.writeHead(400, { 'Content-Type': 'text/xml' });\n res.end(twimlResponse('Invalid content type'));\n return;\n }\n\n let body = '';\n let bodyTooLarge = false;\n\n req.on('data', (chunk) => {\n body += chunk;\n // Body size limit\n if (body.length > MAX_BODY_SIZE) {\n bodyTooLarge = true;\n logBodyTooLarge('sms-webhook', body.length, clientIp);\n req.destroy();\n }\n });\n\n req.on('end', async () => {\n if (bodyTooLarge) {\n res.writeHead(413, { 'Content-Type': 'text/xml' });\n res.end(twimlResponse('Request too large'));\n return;\n }\n\n try {\n const payload = parseFormData(\n body\n ) as unknown as TwilioWebhookPayload;\n\n // Verify Twilio signature\n const twilioSignature = req.headers['x-twilio-signature'] as string;\n const webhookUrl = `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers.host}${req.url}`;\n\n if (\n twilioSignature &&\n !verifyTwilioSignature(\n webhookUrl,\n payload as unknown as Record<string, string>,\n twilioSignature\n )\n ) {\n logSignatureInvalid('sms-webhook', clientIp);\n console.error('[sms-webhook] Invalid Twilio signature');\n res.writeHead(401, { 'Content-Type': 'text/xml' });\n res.end(twimlResponse('Unauthorized'));\n return;\n }\n\n const result = await handleSMSWebhook(payload);\n\n res.writeHead(200, { 'Content-Type': 'text/xml' });\n res.end(twimlResponse(result.response));\n } catch (err) {\n console.error('[sms-webhook] Error:', err);\n res.writeHead(500, { 'Content-Type': 'text/xml' });\n res.end(twimlResponse('Error processing message'));\n }\n });\n return;\n }\n\n // Status callback endpoint (delivery status updates)\n if (url.pathname === '/sms/status' && req.method === 'POST') {\n let body = '';\n req.on('data', (chunk) => {\n body += chunk;\n });\n\n req.on('end', () => {\n try {\n const payload = parseFormData(body);\n console.log(\n `[sms-webhook] Status update: ${payload['MessageSid']} -> ${payload['MessageStatus']}`\n );\n\n // Store status for tracking\n const statusPath = join(\n homedir(),\n '.stackmemory',\n 'sms-status.json'\n );\n const statuses: Record<string, string> = existsSync(statusPath)\n ? JSON.parse(readFileSync(statusPath, 'utf8'))\n : {};\n statuses[payload['MessageSid']] = payload['MessageStatus'];\n writeFileSecure(statusPath, JSON.stringify(statuses, null, 2));\n\n res.writeHead(200, { 'Content-Type': 'text/plain' });\n res.end('OK');\n } catch (err) {\n console.error('[sms-webhook] Status error:', err);\n res.writeHead(500);\n res.end('Error');\n }\n });\n return;\n }\n\n // Server status endpoint\n if (url.pathname === '/status') {\n const config = loadSMSConfig();\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(\n JSON.stringify({\n enabled: config.enabled,\n pendingPrompts: config.pendingPrompts.length,\n })\n );\n return;\n }\n\n res.writeHead(404);\n res.end('Not found');\n }\n );\n\n server.listen(port, () => {\n console.log(`[sms-webhook] Server listening on port ${port}`);\n console.log(\n `[sms-webhook] Incoming messages: http://localhost:${port}/sms/incoming`\n );\n console.log(\n `[sms-webhook] Status callback: http://localhost:${port}/sms/status`\n );\n console.log(`[sms-webhook] Configure these URLs in Twilio console`);\n\n // Start timed cleanup of expired prompts and old actions\n setInterval(() => {\n try {\n const expiredPrompts = cleanupExpiredPrompts();\n const oldActions = cleanupOldActions();\n if (expiredPrompts > 0 || oldActions > 0) {\n logCleanup('sms-webhook', expiredPrompts, oldActions);\n console.log(\n `[sms-webhook] Cleanup: ${expiredPrompts} expired prompts, ${oldActions} old actions`\n );\n }\n } catch {\n // Ignore cleanup errors\n }\n }, CLEANUP_INTERVAL_MS);\n console.log(\n `[sms-webhook] Cleanup interval: every ${CLEANUP_INTERVAL_MS / 1000}s`\n );\n });\n}\n\n// Express middleware for integration\nexport async function smsWebhookMiddleware(\n req: { body: TwilioWebhookPayload },\n res: { type: (t: string) => void; send: (s: string) => void }\n): Promise<void> {\n const result = await handleSMSWebhook(req.body);\n res.type('text/xml');\n res.send(twimlResponse(result.response));\n}\n\n// CLI entry\nif (process.argv[1]?.endsWith('sms-webhook.js')) {\n const port = parseInt(process.env['SMS_WEBHOOK_PORT'] || '3456', 10);\n startWebhookServer(port);\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|