@stackmemoryai/stackmemory
Version:
Project-scoped memory for AI coding tools. Durable context across sessions with MCP integration, frames, smart retrieval, Claude Code skills, and automatic hooks.
372 lines (371 loc) • 11.2 kB
JavaScript
import { fileURLToPath as __fileURLToPath } from 'url';
import { dirname as __pathDirname } from 'path';
const __filename = __fileURLToPath(import.meta.url);
const __dirname = __pathDirname(__filename);
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import { homedir } from "os";
import { execFileSync } from "child_process";
import { randomBytes } from "crypto";
import { writeFileSecure, ensureSecureDir } from "./secure-fs.js";
import { ActionQueueSchema, parseConfigSafe } from "./schemas.js";
import { LinearClient } from "../integrations/linear/client.js";
import { LinearAuthManager } from "../integrations/linear/auth.js";
function parseCommandArgs(command) {
const args = [];
let current = "";
let inSingleQuote = false;
let inDoubleQuote = false;
let escaped = false;
for (let i = 0; i < command.length; i++) {
const char = command[i];
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === "\\" && !inSingleQuote) {
escaped = true;
continue;
}
if (char === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote;
continue;
}
if (char === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote;
continue;
}
if (char === " " && !inSingleQuote && !inDoubleQuote) {
if (current.length > 0) {
args.push(current);
current = "";
}
continue;
}
current += char;
}
if (current.length > 0) {
args.push(current);
}
return args;
}
const SAFE_ACTION_PATTERNS = [
// Git/GitHub CLI commands (limited to safe operations)
{ pattern: /^gh pr (view|list|status|checks) (\d+)$/ },
{ pattern: /^gh pr review (\d+) --approve$/ },
{ pattern: /^gh pr merge (\d+) --squash$/ },
{ pattern: /^gh issue (view|list) (\d+)?$/ },
// NPM commands (limited to safe operations)
{ pattern: /^npm run (build|test|lint|lint:fix|test:run)$/ },
{ pattern: /^npm (test|run build)$/ },
// StackMemory commands
{ pattern: /^stackmemory (status|notify check|context list)$/ },
// Task start with optional --assign-me flag (Linear task ID is UUID format)
{
pattern: /^stackmemory task start ([a-f0-9-]{36})( --assign-me)?$/
},
// Additional StackMemory commands for mobile/WhatsApp
{ pattern: /^stackmemory context show$/ },
{ pattern: /^stackmemory task list$/ },
// Git commands
{ pattern: /^git (status|diff|log|branch)( --[a-z-]+)*$/ },
{ pattern: /^git add -A && git commit$/ },
{ pattern: /^gh pr create --fill$/ },
// Git log with line limit for mobile-friendly output
{ pattern: /^git log --oneline -\d{1,2}$/ },
// WhatsApp/Mobile quick commands
{ pattern: /^status$/i },
{ pattern: /^tasks$/i },
{ pattern: /^context$/i },
{ pattern: /^help$/i },
{ pattern: /^sync$/i },
// Claude Code launcher
{ pattern: /^claude-sm$/ },
// Log viewing (safe read-only)
{ pattern: /^tail -\d+ ~\/\.claude\/logs\/\*\.log$/ },
// Custom aliases (cwm = claude worktree merge)
{ pattern: /^cwm$/ },
// Simple echo/confirmation (no variables)
{
pattern: /^echo "?(Done|OK|Confirmed|Acknowledged|Great work! Time for a coffee break\.)"?$/
}
];
function isActionAllowed(action) {
const trimmed = action.trim();
return SAFE_ACTION_PATTERNS.some(({ pattern, validate }) => {
const match = trimmed.match(pattern);
if (!match) return false;
if (validate && !validate(match)) return false;
return true;
});
}
const QUEUE_PATH = join(homedir(), ".stackmemory", "sms-action-queue.json");
const DEFAULT_QUEUE = {
actions: [],
lastChecked: (/* @__PURE__ */ new Date()).toISOString()
};
function loadActionQueue() {
try {
if (existsSync(QUEUE_PATH)) {
const data = JSON.parse(readFileSync(QUEUE_PATH, "utf8"));
return parseConfigSafe(
ActionQueueSchema,
data,
DEFAULT_QUEUE,
"action-queue"
);
}
} catch {
}
return { ...DEFAULT_QUEUE, lastChecked: (/* @__PURE__ */ new Date()).toISOString() };
}
function saveActionQueue(queue) {
try {
ensureSecureDir(join(homedir(), ".stackmemory"));
writeFileSecure(QUEUE_PATH, JSON.stringify(queue, null, 2));
} catch {
}
}
function queueAction(promptId, response, action) {
const queue = loadActionQueue();
const id = randomBytes(8).toString("hex");
queue.actions.push({
id,
promptId,
response,
action,
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
status: "pending"
});
saveActionQueue(queue);
return id;
}
function getLinearClient() {
const apiKey = process.env["LINEAR_API_KEY"];
if (apiKey && apiKey.startsWith("lin_api_")) {
return new LinearClient({ apiKey });
}
try {
const authManager = new LinearAuthManager();
const tokens = authManager.loadTokens();
if (tokens?.accessToken) {
return new LinearClient({ accessToken: tokens.accessToken });
}
} catch {
}
return null;
}
async function handleSpecialAction(action) {
const taskStartMatch = action.match(
/^stackmemory task start ([a-f0-9-]{36})( --assign-me)?$/
);
if (taskStartMatch) {
const issueId = taskStartMatch[1];
const client = getLinearClient();
if (!client) {
return {
handled: true,
success: false,
error: "Linear not configured. Set LINEAR_API_KEY or run stackmemory linear setup."
};
}
try {
const result = await client.startIssue(issueId);
if (result.success && result.issue) {
return {
handled: true,
success: true,
output: `Started: ${result.issue.identifier} - ${result.issue.title}`
};
}
return {
handled: true,
success: false,
error: result.error || "Failed to start issue"
};
} catch (err) {
return {
handled: true,
success: false,
error: err instanceof Error ? err.message : "Unknown error"
};
}
}
return { handled: false };
}
async function executeActionSafe(action, _response) {
if (!isActionAllowed(action)) {
console.error(`[sms-action] Action not in allowlist: ${action}`);
return {
success: false,
error: `Action not allowed. Only pre-approved commands can be executed via SMS.`
};
}
const specialResult = await handleSpecialAction(action);
if (specialResult.handled) {
return {
success: specialResult.success || false,
output: specialResult.output,
error: specialResult.error
};
}
try {
console.log(`[sms-action] Executing safe action: ${action}`);
const parts = parseCommandArgs(action);
if (parts.length === 0) {
return { success: false, error: "Empty command" };
}
const cmd = parts[0];
const args = parts.slice(1);
const output = execFileSync(cmd, args, {
encoding: "utf8",
timeout: 6e4,
stdio: ["pipe", "pipe", "pipe"],
shell: false
// Explicitly disable shell
});
return { success: true, output };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
return { success: false, error };
}
}
function getPendingActions() {
const queue = loadActionQueue();
return queue.actions.filter((a) => a.status === "pending");
}
function markActionRunning(id) {
const queue = loadActionQueue();
const action = queue.actions.find((a) => a.id === id);
if (action) {
action.status = "running";
saveActionQueue(queue);
}
}
function markActionCompleted(id, result, error) {
const queue = loadActionQueue();
const action = queue.actions.find((a) => a.id === id);
if (action) {
action.status = error ? "failed" : "completed";
action.result = result;
action.error = error;
saveActionQueue(queue);
}
}
async function executeAction(action) {
markActionRunning(action.id);
const result = await executeActionSafe(action.action, action.response);
if (result.success) {
markActionCompleted(action.id, result.output);
} else {
markActionCompleted(action.id, void 0, result.error);
}
return result;
}
async function processAllPendingActions() {
const pending = getPendingActions();
let succeeded = 0;
let failed = 0;
for (const action of pending) {
const result = await executeAction(action);
if (result.success) {
succeeded++;
} else {
failed++;
}
}
return { processed: pending.length, succeeded, failed };
}
function cleanupOldActions() {
const queue = loadActionQueue();
const completed = queue.actions.filter(
(a) => a.status === "completed" || a.status === "failed"
);
if (completed.length > 50) {
const toRemove = completed.slice(0, completed.length - 50);
queue.actions = queue.actions.filter(
(a) => !toRemove.find((r) => r.id === a.id)
);
saveActionQueue(queue);
return toRemove.length;
}
return 0;
}
const ACTION_TEMPLATES = {
// Git/PR actions (PR numbers must be validated as integers)
approvePR: (prNumber) => {
if (!/^\d+$/.test(prNumber)) {
throw new Error("Invalid PR number");
}
return `gh pr review ${prNumber} --approve`;
},
mergePR: (prNumber) => {
if (!/^\d+$/.test(prNumber)) {
throw new Error("Invalid PR number");
}
return `gh pr merge ${prNumber} --squash`;
},
viewPR: (prNumber) => {
if (!/^\d+$/.test(prNumber)) {
throw new Error("Invalid PR number");
}
return `gh pr view ${prNumber}`;
},
// Build actions (no user input)
rebuild: () => `npm run build`,
retest: () => `npm run test:run`,
lint: () => `npm run lint:fix`,
// Status actions (no user input)
status: () => `stackmemory status`,
checkNotifications: () => `stackmemory notify check`
// REMOVED for security - these templates allowed arbitrary user input:
// - requestChanges (allowed arbitrary message)
// - closePR (could be used maliciously)
// - deploy/rollback (too dangerous for SMS)
// - verifyDeployment (allowed arbitrary URL)
// - notifySlack (allowed arbitrary message - command injection)
// - notifyTeam (allowed arbitrary message - command injection)
};
function createAction(template, ...args) {
const fn = ACTION_TEMPLATES[template];
if (typeof fn === "function") {
return fn(...args);
}
return fn;
}
function startActionWatcher(intervalMs = 5e3) {
console.log(
`[sms-action] Starting action watcher (interval: ${intervalMs}ms)`
);
return setInterval(() => {
const pending = getPendingActions();
if (pending.length > 0) {
console.log(`[sms-action] Found ${pending.length} pending action(s)`);
processAllPendingActions();
}
}, intervalMs);
}
function handleSMSResponse(promptId, response, action) {
if (action) {
const actionId = queueAction(promptId, response, action);
console.log(`[sms-action] Queued action ${actionId}: ${action}`);
}
}
export {
ACTION_TEMPLATES,
cleanupOldActions,
createAction,
executeAction,
executeActionSafe,
getPendingActions,
handleSMSResponse,
loadActionQueue,
markActionCompleted,
markActionRunning,
processAllPendingActions,
queueAction,
saveActionQueue,
startActionWatcher
};
//# sourceMappingURL=sms-action-runner.js.map