@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.
322 lines (321 loc) • 9.3 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 { z } from "zod";
import { resolve as pathResolve, relative as pathRelative } from "path";
import { ValidationError, ErrorCode } from "../errors/index.js";
const SENSITIVE_PATTERNS = [
/\b(api[_-]?key|apikey)\s*[:=]\s*['"]?[\w-]+['"]?/gi,
/\b(secret|password|token|credential|auth)\s*[:=]\s*['"]?[\w-]+['"]?/gi,
/\b(lin_api_[\w]+)/gi,
// Linear API keys
/\b(lin_oauth_[\w]+)/gi,
// Linear OAuth tokens
/\b(sk-[\w]+)/gi,
// OpenAI-style API keys
/\b(npm_[\w]+)/gi,
// NPM tokens
/\b(ghp_[\w]+)/gi,
// GitHub personal access tokens
/\b(ghs_[\w]+)/gi,
// GitHub secret tokens
/Bearer\s+[\w.-]+/gi,
/Basic\s+[\w=]+/gi,
/postgres(ql)?:\/\/[^@\s]+:[^@\s]+@/gi
// Database URLs with credentials
];
function redactSensitiveData(input) {
let result = input;
for (const pattern of SENSITIVE_PATTERNS) {
pattern.lastIndex = 0;
result = result.replace(pattern, "[REDACTED]");
}
return result;
}
function containsSensitiveData(input) {
return SENSITIVE_PATTERNS.some((pattern) => {
pattern.lastIndex = 0;
return pattern.test(input);
});
}
function sanitizeForSqlLike(input) {
if (!input) return "";
return input.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_").replace(/'/g, "''");
}
function sanitizeIdentifier(input) {
if (!input) {
throw new ValidationError(
"Identifier cannot be empty",
ErrorCode.VALIDATION_FAILED
);
}
const sanitized = input.replace(/[^a-zA-Z0-9_]/g, "");
if (sanitized !== input) {
throw new ValidationError(
`Invalid identifier: ${input}. Only alphanumeric characters and underscores are allowed.`,
ErrorCode.VALIDATION_FAILED,
{ input, sanitized }
);
}
const sqlKeywords = [
"DROP",
"DELETE",
"INSERT",
"UPDATE",
"SELECT",
"UNION",
"ALTER",
"CREATE",
"TRUNCATE",
"EXEC",
"EXECUTE"
];
if (sqlKeywords.includes(sanitized.toUpperCase())) {
throw new ValidationError(
`Invalid identifier: ${input}. SQL keywords are not allowed.`,
ErrorCode.VALIDATION_FAILED,
{ input }
);
}
return sanitized;
}
const ALLOWED_TABLES = [
"frames",
"events",
"anchors",
"contexts",
"task_cache",
"schema_version",
"attention_log",
"traces"
];
function validateTableName(tableName) {
const sanitized = sanitizeIdentifier(tableName);
if (!ALLOWED_TABLES.includes(sanitized)) {
throw new ValidationError(
`Invalid table name: ${tableName}. Allowed tables: ${ALLOWED_TABLES.join(", ")}`,
ErrorCode.VALIDATION_FAILED,
{ tableName, allowed: ALLOWED_TABLES }
);
}
return sanitized;
}
function sanitizeFilePath(input, baseDir) {
if (!input) {
throw new ValidationError(
"File path cannot be empty",
ErrorCode.VALIDATION_FAILED
);
}
if (input.includes("\0")) {
throw new ValidationError(
"File path contains invalid characters",
ErrorCode.VALIDATION_FAILED,
{ reason: "null_byte" }
);
}
if (input.includes("..")) {
throw new ValidationError(
"Path traversal not allowed",
ErrorCode.VALIDATION_FAILED,
{ path: input }
);
}
if (baseDir) {
const resolvedPath = pathResolve(baseDir, input);
const relativePath = pathRelative(baseDir, resolvedPath);
if (relativePath.startsWith("..") || pathResolve(relativePath) === resolvedPath) {
if (relativePath.startsWith("..")) {
throw new ValidationError(
"Path escapes base directory",
ErrorCode.VALIDATION_FAILED,
{ path: input, baseDir }
);
}
}
return resolvedPath;
}
return input;
}
const SENSITIVE_FIELD_NAMES = [
"password",
"token",
"apikey",
"api_key",
"secret",
"credential",
"authorization",
"auth",
"accesstoken",
"access_token",
"refreshtoken",
"refresh_token"
];
function isSensitiveFieldName(key) {
const lowerKey = key.toLowerCase();
return SENSITIVE_FIELD_NAMES.some((sf) => lowerKey.includes(sf));
}
function sanitizeForLogging(obj) {
if (obj === null || obj === void 0) {
return obj;
}
if (typeof obj === "string") {
return redactSensitiveData(obj);
}
if (Array.isArray(obj)) {
return obj.map(sanitizeForLogging);
}
if (typeof obj === "object") {
const sanitized = {};
for (const [key, value] of Object.entries(obj)) {
if (isSensitiveFieldName(key)) {
sanitized[key] = "[REDACTED]";
} else {
sanitized[key] = sanitizeForLogging(value);
}
}
return sanitized;
}
return obj;
}
const InputSchemas = {
// Frame-related
frameId: z.string().uuid("Invalid frame ID format"),
frameName: z.string().min(1, "Frame name is required").max(500, "Frame name too long").refine(
(val) => !containsSensitiveData(val),
"Frame name may contain sensitive data"
),
frameType: z.enum([
"task",
"subtask",
"tool_scope",
"review",
"write",
"debug"
]),
// Query-related
searchQuery: z.string().min(1, "Search query is required").max(1e3, "Search query too long").transform((val) => sanitizeForSqlLike(val)),
limit: z.number().int().min(1).max(1e3).default(50),
offset: z.number().int().min(0).default(0),
// Task-related
taskTitle: z.string().min(1, "Task title is required").max(500, "Task title too long"),
taskDescription: z.string().max(1e4, "Description too long").optional(),
taskPriority: z.enum(["low", "medium", "high", "urgent", "critical"]),
taskStatus: z.enum([
"pending",
"in_progress",
"completed",
"blocked",
"cancelled"
]),
// Anchor-related
anchorType: z.enum([
"FACT",
"DECISION",
"CONSTRAINT",
"INTERFACE_CONTRACT",
"TODO",
"RISK"
]),
anchorText: z.string().min(1, "Anchor text is required").max(1e4, "Anchor text too long"),
priority: z.number().int().min(0).max(10).default(5),
// File path
filePath: z.string().min(1, "File path is required").max(4096, "File path too long").refine((val) => !val.includes("\0"), "Invalid characters in path").refine((val) => !val.includes(".."), "Path traversal not allowed"),
// Project ID
projectId: z.string().min(1, "Project ID is required").max(100, "Project ID too long").regex(
/^[a-zA-Z0-9_-]+$/,
"Project ID can only contain letters, numbers, hyphens, and underscores"
),
// Session ID
sessionId: z.string().uuid("Invalid session ID format").optional(),
// Date/time
timestamp: z.number().int().positive(),
dateString: z.string().datetime(),
// Generic content (with sensitive data check)
safeContent: z.string().max(1e5, "Content too large").refine(
(val) => !containsSensitiveData(val),
"Content may contain sensitive data that should not be stored"
),
// Email
email: z.string().email("Invalid email format").max(254, "Email too long").transform((val) => val.toLowerCase()),
// URL
url: z.string().url("Invalid URL format").max(2048, "URL too long")
};
function validateInput(schema, input, context) {
const result = schema.safeParse(input);
if (!result.success) {
const errors = result.error.errors.map((e) => ({
path: e.path.join("."),
message: e.message
}));
throw new ValidationError(
`Invalid input for ${context}: ${errors.map((e) => e.message).join(", ")}`,
ErrorCode.VALIDATION_FAILED,
{ context, errors }
);
}
return result.data;
}
function createAggregateSchema(allowedFields) {
return z.object({
groupBy: z.array(z.string()).refine(
(fields) => fields.every((f) => allowedFields.includes(f)),
`Group by fields must be one of: ${allowedFields.join(", ")}`
),
metrics: z.array(
z.object({
operation: z.enum(["COUNT", "SUM", "AVG", "MIN", "MAX"]),
field: z.string().refine(
(f) => f === "*" || allowedFields.includes(f),
`Field must be one of: ${allowedFields.join(", ")}`
),
alias: z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/).optional()
})
),
orderBy: z.string().refine(
(f) => allowedFields.includes(f),
`Order by must be one of: ${allowedFields.join(", ")}`
).optional(),
limit: z.number().int().min(1).max(1e3).optional()
});
}
function validateShellArg(arg) {
if (!arg) return "";
const dangerousChars = /[;&|`$(){}[\]<>!#*?~\n\r]/;
if (dangerousChars.test(arg)) {
throw new ValidationError(
"Argument contains potentially dangerous shell characters",
ErrorCode.VALIDATION_FAILED,
{ arg: arg.substring(0, 50) }
);
}
return arg;
}
function safeJsonParse(input, schema) {
try {
const parsed = JSON.parse(input);
if (schema) {
return validateInput(schema, parsed, "JSON parse");
}
return parsed;
} catch {
return null;
}
}
export {
InputSchemas,
SENSITIVE_PATTERNS,
containsSensitiveData,
createAggregateSchema,
redactSensitiveData,
safeJsonParse,
sanitizeFilePath,
sanitizeForLogging,
sanitizeForSqlLike,
sanitizeIdentifier,
validateInput,
validateShellArg,
validateTableName
};
//# sourceMappingURL=input-sanitizer.js.map