companionbot 0.3.0 → 0.4.1

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.
@@ -1,5 +1,27 @@
1
+ import { randomBytes } from "crypto";
1
2
  import { chat, MODELS } from "../../ai/claude.js";
2
- import { getHistory, clearHistory, getModel, setModel, setCurrentChatId, } from "../../session/state.js";
3
+ // Reset 토큰 관리 (1분 만료)
4
+ const resetTokens = new Map();
5
+ function generateResetToken(chatId) {
6
+ const token = randomBytes(8).toString("hex");
7
+ const expiresAt = Date.now() + 60000; // 1분 후 만료
8
+ resetTokens.set(chatId, { token, expiresAt });
9
+ return token;
10
+ }
11
+ function validateResetToken(chatId, token) {
12
+ const stored = resetTokens.get(chatId);
13
+ if (!stored)
14
+ return false;
15
+ if (Date.now() > stored.expiresAt) {
16
+ resetTokens.delete(chatId);
17
+ return false;
18
+ }
19
+ if (stored.token !== token)
20
+ return false;
21
+ resetTokens.delete(chatId); // 사용 후 삭제
22
+ return true;
23
+ }
24
+ import { getHistory, clearHistory, getModel, setModel, runWithChatId, } from "../../session/state.js";
3
25
  import { hasBootstrap, loadRecentMemories, getWorkspacePath, } from "../../workspace/index.js";
4
26
  import { getSecret, setSecret, deleteSecret } from "../../config/secrets.js";
5
27
  import { getReminders } from "../../reminders/index.js";
@@ -13,32 +35,33 @@ export function registerCommands(bot) {
13
35
  const chatId = ctx.chat.id;
14
36
  clearHistory(chatId);
15
37
  setModel(chatId, "sonnet");
16
- setCurrentChatId(chatId);
17
38
  // 워크스페이스 캐시 무효화
18
39
  invalidateWorkspaceCache();
19
40
  // BOOTSTRAP 모드 확인
20
41
  const isBootstrap = await hasBootstrap();
21
42
  if (isBootstrap) {
22
- // 온보딩 모드: 봇이 먼저 인사
23
- await ctx.replyWithChatAction("typing");
24
- const history = getHistory(chatId);
25
- const modelId = getModel(chatId);
26
- const systemPrompt = await buildSystemPrompt(modelId);
27
- // 메시지 생성 요청
28
- history.push({
29
- role: "user",
30
- content: "[시스템: 사용자가 /start를 눌렀습니다. 온보딩을 시작하세요.]",
43
+ // 온보딩 모드: 봇이 먼저 인사 (runWithChatId로 감싸서 도구가 chatId 접근 가능)
44
+ await runWithChatId(chatId, async () => {
45
+ await ctx.replyWithChatAction("typing");
46
+ const history = getHistory(chatId);
47
+ const modelId = getModel(chatId);
48
+ const systemPrompt = await buildSystemPrompt(modelId);
49
+ // 첫 메시지 생성 요청
50
+ history.push({
51
+ role: "user",
52
+ content: "[시스템: 사용자가 /start를 눌렀습니다. 온보딩을 시작하세요.]",
53
+ });
54
+ try {
55
+ const response = await chat(history, systemPrompt, modelId);
56
+ history.push({ role: "assistant", content: response });
57
+ await ctx.reply(response);
58
+ }
59
+ catch (error) {
60
+ console.error("Bootstrap start error:", error);
61
+ await ctx.reply("안녕! 반가워. 난 방금 태어난 AI야. 아직 이름도 없어.\n" +
62
+ "너와 함께 나를 만들어가고 싶은데... 혹시 이름 지어줄 수 있어?");
63
+ }
31
64
  });
32
- try {
33
- const response = await chat(history, systemPrompt, modelId);
34
- history.push({ role: "assistant", content: response });
35
- await ctx.reply(response);
36
- }
37
- catch (error) {
38
- console.error("Bootstrap start error:", error);
39
- await ctx.reply("안녕! 반가워. 난 방금 태어난 AI야. 아직 이름도 없어.\n" +
40
- "너와 함께 나를 만들어가고 싶은데... 혹시 이름 지어줄 수 있어?");
41
- }
42
65
  }
43
66
  else {
44
67
  // 일반 모드
@@ -51,20 +74,30 @@ export function registerCommands(bot) {
51
74
  `/reset - 페르소나 리셋`);
52
75
  }
53
76
  });
54
- // /reset 명령어 - 페르소나 리셋
77
+ // /reset 명령어 - 페르소나 리셋 (토큰 기반)
55
78
  bot.command("reset", async (ctx) => {
56
- await ctx.reply("정말 페르소나를 리셋할까요?\n" +
79
+ const chatId = ctx.chat.id;
80
+ const token = generateResetToken(chatId);
81
+ await ctx.reply("⚠️ 정말 페르소나를 리셋할까요?\n" +
57
82
  "모든 설정이 초기화되고 온보딩을 다시 진행합니다.\n\n" +
58
- "확인하려면 /confirm_reset입력하세요.");
83
+ `확인하려면 /confirm_reset_${token}입력하세요.\n` +
84
+ "(1분 후 만료)");
59
85
  });
60
- bot.command("confirm_reset", async (ctx) => {
86
+ // /confirm_reset_<token> 패턴 매칭
87
+ bot.hears(/^\/confirm_reset_([a-f0-9]+)$/, async (ctx) => {
88
+ const chatId = ctx.chat.id;
89
+ const token = ctx.match[1];
90
+ if (!validateResetToken(chatId, token)) {
91
+ await ctx.reply("❌ 유효하지 않거나 만료된 토큰입니다.\n/reset 으로 다시 시도하세요.");
92
+ return;
93
+ }
61
94
  const { initWorkspace } = await import("../../workspace/index.js");
62
95
  const { rm } = await import("fs/promises");
63
96
  try {
64
97
  await rm(getWorkspacePath(), { recursive: true, force: true });
65
98
  await initWorkspace();
66
99
  invalidateWorkspaceCache();
67
- clearHistory(ctx.chat.id);
100
+ clearHistory(chatId);
68
101
  await ctx.reply("✓ 페르소나가 리셋되었습니다.\n" +
69
102
  "/start 를 눌러 온보딩을 시작하세요.");
70
103
  }
@@ -296,11 +329,24 @@ export function registerCommands(bot) {
296
329
  `설정 방법:\n` +
297
330
  `1. https://openweathermap.org 가입\n` +
298
331
  `2. API Keys에서 키 발급\n` +
299
- `3. /weather_setup YOUR_API_KEY 입력`);
332
+ `3. /weather_setup YOUR_API_KEY 입력\n\n` +
333
+ `⚠️ DM에서만 설정 가능합니다 (보안)`);
334
+ return;
335
+ }
336
+ // DM에서만 설정 가능
337
+ if (ctx.chat.type !== "private") {
338
+ await ctx.reply("⚠️ API 키는 DM에서만 설정할 수 있어요.\n보안을 위해 개인 채팅으로 보내주세요.");
300
339
  return;
301
340
  }
341
+ // 메시지 삭제 (API 키 노출 방지)
342
+ try {
343
+ await ctx.api.deleteMessage(ctx.chat.id, ctx.message.message_id);
344
+ }
345
+ catch {
346
+ // 삭제 실패해도 계속 진행
347
+ }
302
348
  await setSecret("openweathermap-api-key", arg);
303
- await ctx.reply("✓ 날씨 API 키가 설정되었습니다!");
349
+ await ctx.reply("✓ 날씨 API 키가 설정되었습니다! (보안을 위해 메시지 삭제됨)");
304
350
  });
305
351
  // /reminders 명령어 - 알림 목록
306
352
  bot.command("reminders", async (ctx) => {
@@ -382,16 +428,29 @@ export function registerCommands(bot) {
382
428
  ` - 유형: 데스크톱 앱\n` +
383
429
  ` - 리디렉션 URI: http://localhost:3847/oauth2callback\n\n` +
384
430
  `5. 클라이언트 ID와 Secret 복사 후:\n` +
385
- `/calendar_setup CLIENT_ID CLIENT_SECRET`);
431
+ `/calendar_setup CLIENT_ID CLIENT_SECRET\n\n` +
432
+ `⚠️ DM에서만 설정 가능합니다 (보안)`);
433
+ return;
434
+ }
435
+ // DM에서만 설정 가능
436
+ if (ctx.chat.type !== "private") {
437
+ await ctx.reply("⚠️ API 키는 DM에서만 설정할 수 있어요.\n보안을 위해 개인 채팅으로 보내주세요.");
386
438
  return;
387
439
  }
388
440
  // credentials 설정
389
441
  if (args.length === 2) {
390
442
  const [clientId, clientSecret] = args;
443
+ // 메시지 삭제 (credentials 노출 방지)
444
+ try {
445
+ await ctx.api.deleteMessage(ctx.chat.id, ctx.message.message_id);
446
+ }
447
+ catch {
448
+ // 삭제 실패해도 계속 진행
449
+ }
391
450
  await setCredentials(clientId, clientSecret);
392
451
  const authUrl = await getAuthUrl();
393
452
  if (authUrl) {
394
- await ctx.reply(`✅ Credentials 저장됨!\n\n` +
453
+ await ctx.reply(`✅ Credentials 저장됨! (보안을 위해 메시지 삭제됨)\n\n` +
395
454
  `아래 링크에서 인증해주세요:\n${authUrl}\n\n` +
396
455
  `인증 완료 후 자동으로 연결됩니다.`);
397
456
  // 인증 서버 시작
@@ -1,5 +1,5 @@
1
1
  import { chat } from "../../ai/claude.js";
2
- import { getHistory, getModel, setCurrentChatId, } from "../../session/state.js";
2
+ import { getHistory, getModel, runWithChatId, } from "../../session/state.js";
3
3
  import { updateLastMessageTime } from "../../heartbeat/index.js";
4
4
  import { extractUrls, fetchWebContent, buildSystemPrompt, } from "../utils/index.js";
5
5
  /**
@@ -9,60 +9,61 @@ export function registerMessageHandlers(bot) {
9
9
  // 사진 메시지 처리
10
10
  bot.on("message:photo", async (ctx) => {
11
11
  const chatId = ctx.chat.id;
12
- setCurrentChatId(chatId);
13
- const history = getHistory(chatId);
14
- const modelId = getModel(chatId);
15
- await ctx.replyWithChatAction("typing");
16
- try {
17
- // 가장 큰 사진 선택 (마지막이 가장 큼)
18
- const photo = ctx.message.photo[ctx.message.photo.length - 1];
19
- const file = await ctx.api.getFile(photo.file_id);
20
- if (!file.file_path) {
21
- await ctx.reply("사진을 가져올 수 없어.");
22
- return;
23
- }
24
- // 파일 크기 제한 (10MB)
25
- const MAX_IMAGE_SIZE = 10 * 1024 * 1024;
26
- if (file.file_size && file.file_size > MAX_IMAGE_SIZE) {
27
- await ctx.reply("사진이 너무 커. 10MB 이하로 보내줄래?");
28
- return;
29
- }
30
- // 파일 다운로드
31
- const fileUrl = `https://api.telegram.org/file/bot${bot.token}/${file.file_path}`;
32
- const response = await fetch(fileUrl);
33
- const buffer = await response.arrayBuffer();
34
- const base64 = Buffer.from(buffer).toString("base64");
35
- // 캡션이 있으면 사용, 없으면 기본 질문
36
- const caption = ctx.message.caption || "이 사진에 뭐가 있어?";
37
- // 이미지와 텍스트를 함께 전송
38
- const imageContent = [
39
- {
40
- type: "image",
41
- source: {
42
- type: "base64",
43
- media_type: "image/jpeg",
44
- data: base64,
12
+ await runWithChatId(chatId, async () => {
13
+ const history = getHistory(chatId);
14
+ const modelId = getModel(chatId);
15
+ await ctx.replyWithChatAction("typing");
16
+ try {
17
+ // 가장 큰 사진 선택 (마지막이 가장 큼)
18
+ const photo = ctx.message.photo[ctx.message.photo.length - 1];
19
+ const file = await ctx.api.getFile(photo.file_id);
20
+ if (!file.file_path) {
21
+ await ctx.reply("사진을 가져올 수 없어.");
22
+ return;
23
+ }
24
+ // 파일 크기 제한 (10MB)
25
+ const MAX_IMAGE_SIZE = 10 * 1024 * 1024;
26
+ if (file.file_size && file.file_size > MAX_IMAGE_SIZE) {
27
+ await ctx.reply("사진이 너무 커. 10MB 이하로 보내줄래?");
28
+ return;
29
+ }
30
+ // 파일 다운로드
31
+ const fileUrl = `https://api.telegram.org/file/bot${bot.token}/${file.file_path}`;
32
+ const response = await fetch(fileUrl);
33
+ const buffer = await response.arrayBuffer();
34
+ const base64 = Buffer.from(buffer).toString("base64");
35
+ // 캡션이 있으면 사용, 없으면 기본 질문
36
+ const caption = ctx.message.caption || "이 사진에 뭐가 있어?";
37
+ // 이미지와 텍스트를 함께 전송
38
+ const imageContent = [
39
+ {
40
+ type: "image",
41
+ source: {
42
+ type: "base64",
43
+ media_type: "image/jpeg",
44
+ data: base64,
45
+ },
46
+ },
47
+ {
48
+ type: "text",
49
+ text: caption,
45
50
  },
46
- },
47
- {
48
- type: "text",
49
- text: caption,
50
- },
51
- ];
52
- history.push({ role: "user", content: imageContent });
53
- const systemPrompt = await buildSystemPrompt(modelId);
54
- const result = await chat(history, systemPrompt, modelId);
55
- history.push({ role: "assistant", content: result });
56
- // 히스토리 제한
57
- if (history.length > 20) {
58
- history.splice(0, history.length - 20);
51
+ ];
52
+ history.push({ role: "user", content: imageContent });
53
+ const systemPrompt = await buildSystemPrompt(modelId);
54
+ const result = await chat(history, systemPrompt, modelId);
55
+ history.push({ role: "assistant", content: result });
56
+ // 히스토리 제한
57
+ if (history.length > 20) {
58
+ history.splice(0, history.length - 20);
59
+ }
60
+ await ctx.reply(result);
59
61
  }
60
- await ctx.reply(result);
61
- }
62
- catch (error) {
63
- console.error("Photo error:", error);
64
- await ctx.reply("사진 분석 중 오류가 발생했어.");
65
- }
62
+ catch (error) {
63
+ console.error("Photo error:", error);
64
+ await ctx.reply("사진 분석 중 오류가 발생했어.");
65
+ }
66
+ });
66
67
  });
67
68
  // 일반 메시지 처리
68
69
  bot.on("message:text", async (ctx) => {
@@ -71,45 +72,45 @@ export function registerMessageHandlers(bot) {
71
72
  // 빈 메시지 무시
72
73
  if (!userMessage.trim())
73
74
  return;
74
- // 현재 chatId 설정 (도구에서 사용)
75
- setCurrentChatId(chatId);
76
- // Heartbeat 마지막 대화 시간 업데이트
77
- updateLastMessageTime(chatId);
78
- const history = getHistory(chatId);
79
- const modelId = getModel(chatId);
80
- await ctx.replyWithChatAction("typing");
81
- // URL 감지 및 내용 가져오기 (병렬 처리)
82
- const urls = extractUrls(userMessage);
83
- let enrichedMessage = userMessage;
84
- if (urls.length > 0) {
85
- const urlsToFetch = urls.slice(0, 3); // 최대 3개 URL
86
- const contents = await Promise.all(urlsToFetch.map((url) => fetchWebContent(url)));
87
- const webContents = contents
88
- .map((content, index) => {
89
- if (!content)
90
- return null;
91
- return `\n\n---\n📎 Link: ${urlsToFetch[index]}\n📌 Title: ${content.title}\n📄 Content:\n${content.content}\n---`;
92
- })
93
- .filter((item) => item !== null);
94
- if (webContents.length > 0) {
95
- enrichedMessage = userMessage + webContents.join("\n");
75
+ await runWithChatId(chatId, async () => {
76
+ // Heartbeat 마지막 대화 시간 업데이트
77
+ updateLastMessageTime(chatId);
78
+ const history = getHistory(chatId);
79
+ const modelId = getModel(chatId);
80
+ await ctx.replyWithChatAction("typing");
81
+ // URL 감지 및 내용 가져오기 (병렬 처리)
82
+ const urls = extractUrls(userMessage);
83
+ let enrichedMessage = userMessage;
84
+ if (urls.length > 0) {
85
+ const urlsToFetch = urls.slice(0, 3); // 최대 3개 URL
86
+ const contents = await Promise.all(urlsToFetch.map((url) => fetchWebContent(url)));
87
+ const webContents = contents
88
+ .map((content, index) => {
89
+ if (!content)
90
+ return null;
91
+ return `\n\n---\n📎 Link: ${urlsToFetch[index]}\n📌 Title: ${content.title}\n📄 Content:\n${content.content}\n---`;
92
+ })
93
+ .filter((item) => item !== null);
94
+ if (webContents.length > 0) {
95
+ enrichedMessage = userMessage + webContents.join("\n");
96
+ }
97
+ }
98
+ // 사용자 메시지 추가 (URL 내용 포함)
99
+ history.push({ role: "user", content: enrichedMessage });
100
+ try {
101
+ const systemPrompt = await buildSystemPrompt(modelId);
102
+ const response = await chat(history, systemPrompt, modelId);
103
+ history.push({ role: "assistant", content: response });
104
+ // 히스토리 제한 (최근 20개 메시지만 유지)
105
+ if (history.length > 20) {
106
+ history.splice(0, history.length - 20);
107
+ }
108
+ await ctx.reply(response);
96
109
  }
97
- }
98
- // 사용자 메시지 추가 (URL 내용 포함)
99
- history.push({ role: "user", content: enrichedMessage });
100
- try {
101
- const systemPrompt = await buildSystemPrompt(modelId);
102
- const response = await chat(history, systemPrompt, modelId);
103
- history.push({ role: "assistant", content: response });
104
- // 히스토리 제한 (최근 20개 메시지만 유지)
105
- if (history.length > 20) {
106
- history.splice(0, history.length - 20);
110
+ catch (error) {
111
+ console.error("Chat error:", error);
112
+ await ctx.reply("뭔가 잘못됐어. 다시 시도해줄래?");
107
113
  }
108
- await ctx.reply(response);
109
- }
110
- catch (error) {
111
- console.error("Chat error:", error);
112
- await ctx.reply("뭔가 잘못됐어. 다시 시도해줄래?");
113
- }
114
+ });
114
115
  });
115
116
  }
@@ -6,6 +6,7 @@ import { promisify } from "util";
6
6
  import { randomUUID } from "crypto";
7
7
  import { MODELS } from "../ai/claude.js";
8
8
  import { getCurrentChatId, setModel, getModel } from "../session/state.js";
9
+ // Note: getCurrentChatId uses AsyncLocalStorage - must be called within runWithChatId context
9
10
  import { getWorkspacePath, saveWorkspaceFile, appendToMemory, deleteBootstrap, } from "../workspace/index.js";
10
11
  import { getSecret } from "../config/secrets.js";
11
12
  import { createReminder, deleteReminder, getReminders, parseTimeExpression, } from "../reminders/index.js";
@@ -20,6 +21,24 @@ const execAsync = promisify(exec);
20
21
  const sessions = new Map();
21
22
  // Output buffer 최대 크기 (라인 수)
22
23
  const MAX_OUTPUT_LINES = 1000;
24
+ // 세션 정리 간격 및 TTL (메모리 누수 방지)
25
+ const SESSION_CLEANUP_INTERVAL_MS = 10 * 60 * 1000; // 10분마다 정리
26
+ const SESSION_TTL_MS = 60 * 60 * 1000; // 완료된 세션 1시간 후 삭제
27
+ // 완료된 세션 자동 정리 함수
28
+ function cleanupStaleSessions() {
29
+ const now = Date.now();
30
+ for (const [id, session] of sessions) {
31
+ // 완료/에러/종료된 세션만 정리
32
+ if (session.status !== "running" && session.endTime) {
33
+ const age = now - session.endTime.getTime();
34
+ if (age > SESSION_TTL_MS) {
35
+ sessions.delete(id);
36
+ }
37
+ }
38
+ }
39
+ }
40
+ // 주기적 세션 정리 시작
41
+ setInterval(cleanupStaleSessions, SESSION_CLEANUP_INTERVAL_MS);
23
42
  function appendOutput(session, data) {
24
43
  const lines = data.split("\n");
25
44
  session.outputBuffer.push(...lines);
@@ -39,6 +58,40 @@ function getAllowedPaths() {
39
58
  ];
40
59
  }
41
60
  // 위험한 파일 패턴
61
+ // SSRF 방지: 사설 IP 체크
62
+ function isPrivateIP(hostname) {
63
+ // IPv4 사설 IP 패턴
64
+ const privateIPv4Patterns = [
65
+ /^127\./, // 127.0.0.0/8 loopback
66
+ /^10\./, // 10.0.0.0/8
67
+ /^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.0.0/12
68
+ /^192\.168\./, // 192.168.0.0/16
69
+ /^0\./, // 0.0.0.0/8
70
+ /^169\.254\./, // link-local
71
+ ];
72
+ // IPv6 사설/특수 주소
73
+ const privateIPv6Patterns = [
74
+ /^::1$/, // loopback
75
+ /^fe80:/i, // link-local
76
+ /^fd[0-9a-f]{2}:/i, // unique local (fd00::/8)
77
+ /^fc[0-9a-f]{2}:/i, // unique local (fc00::/7)
78
+ /^::ffff:(127\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|192\.168\.)/i, // IPv4-mapped
79
+ ];
80
+ // localhost 체크
81
+ if (hostname === 'localhost' || hostname === 'localhost.localdomain') {
82
+ return true;
83
+ }
84
+ // IPv4 체크
85
+ if (privateIPv4Patterns.some(p => p.test(hostname))) {
86
+ return true;
87
+ }
88
+ // IPv6 체크 (브라켓 제거)
89
+ const ipv6 = hostname.replace(/^\[|\]$/g, '');
90
+ if (privateIPv6Patterns.some(p => p.test(ipv6))) {
91
+ return true;
92
+ }
93
+ return false;
94
+ }
42
95
  const DANGEROUS_PATTERNS = [
43
96
  /\.bashrc$/,
44
97
  /\.zshrc$/,
@@ -51,6 +104,12 @@ const DANGEROUS_PATTERNS = [
51
104
  /\.npmrc$/,
52
105
  ];
53
106
  function isPathAllowed(targetPath) {
107
+ // ⚠️ TOCTOU (Time-of-check to time-of-use) 주의:
108
+ // realpathSync() 호출과 실제 파일 작업 사이에 심볼릭 링크가 변경될 수 있음.
109
+ // 완전한 방지를 위해서는 O_NOFOLLOW 플래그로 파일을 열어야 하지만,
110
+ // Node.js fs API에서는 제한적으로만 지원됨 (fs.open의 O_NOFOLLOW 미지원).
111
+ // 현재 구현은 기본적인 심볼릭 링크 해석을 통한 검증만 수행.
112
+ // 높은 보안이 필요한 환경에서는 chroot/namespace 격리를 권장.
54
113
  try {
55
114
  const resolved = path.resolve(targetPath);
56
115
  // 위험한 파일 패턴 차단
@@ -69,7 +128,8 @@ function isPathAllowed(targetPath) {
69
128
  realPath = path.join(fsSync.realpathSync(parentDir), path.basename(resolved));
70
129
  }
71
130
  catch {
72
- realPath = resolved;
131
+ // 부모 디렉토리도 resolve 실패 시 거부 (존재하지 않거나 접근 불가)
132
+ return false;
73
133
  }
74
134
  }
75
135
  const allowedPaths = getAllowedPaths();
@@ -81,6 +141,7 @@ function isPathAllowed(targetPath) {
81
141
  });
82
142
  }
83
143
  catch {
144
+ // 어떤 예외든 검증 실패로 처리 (fail-safe)
84
145
  return false;
85
146
  }
86
147
  }
@@ -771,9 +832,9 @@ export async function executeTool(name, input) {
771
832
  "grep", "find", "wc", "sort", "uniq", "diff", "echo", "date",
772
833
  "which", "env", "printenv"
773
834
  ];
774
- // 명령어 체이닝/치환 차단 (;, &&, ||, |, `, $(), ${})
775
- if (/[;&|`]|\$\(|\$\{/.test(command)) {
776
- return `Error: Command chaining and substitution not allowed.`;
835
+ // 명령어 체이닝/치환/리디렉션 차단 (;, &&, ||, |, `, $(), ${}, 개행, >, <)
836
+ if (/[;&|`\n\r]|\$\(|\$\{|>>|>|</.test(command)) {
837
+ return `Error: Command chaining, substitution, and redirection not allowed.`;
777
838
  }
778
839
  // 첫 번째 명령어 추출
779
840
  const parts = command.trim().split(/\s+/);
@@ -1295,6 +1356,16 @@ ${"─".repeat(40)}`;
1295
1356
  if (!url.startsWith("http://") && !url.startsWith("https://")) {
1296
1357
  return "Error: URL must start with http:// or https://";
1297
1358
  }
1359
+ // SSRF 방지: 사설 IP 차단
1360
+ try {
1361
+ const parsedUrl = new URL(url);
1362
+ if (isPrivateIP(parsedUrl.hostname)) {
1363
+ return "Error: Access to private/internal addresses is not allowed.";
1364
+ }
1365
+ }
1366
+ catch {
1367
+ return "Error: Invalid URL format.";
1368
+ }
1298
1369
  try {
1299
1370
  const response = await fetch(url, {
1300
1371
  headers: {
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Path validation utilities for security
3
+ * 보안 크리티컬 - 허용된 경로만 접근 가능하도록 검증
4
+ */
5
+ import * as fsSync from "fs";
6
+ import * as path from "path";
7
+ import { getWorkspacePath } from "../workspace/index.js";
8
+ // 홈 디렉토리
9
+ const home = process.env.HOME || "";
10
+ // 위험한 파일 패턴
11
+ export const DANGEROUS_PATTERNS = [
12
+ /\.bashrc$/,
13
+ /\.zshrc$/,
14
+ /\.bash_profile$/,
15
+ /\.profile$/,
16
+ /\.ssh\//,
17
+ /\.git\/hooks\//,
18
+ /\.git\/config$/,
19
+ /\.env$/,
20
+ /\.npmrc$/,
21
+ ];
22
+ /**
23
+ * 허용된 디렉토리 목록 반환
24
+ */
25
+ export function getAllowedPaths() {
26
+ return [
27
+ path.join(home, "Documents"),
28
+ path.join(home, "projects"),
29
+ getWorkspacePath(),
30
+ ];
31
+ }
32
+ /**
33
+ * 주어진 경로가 허용된 디렉토리 내에 있는지 검증
34
+ *
35
+ * 보안 고려사항:
36
+ * - 심볼릭 링크를 해석하여 실제 경로 확인
37
+ * - 위험한 파일 패턴 차단
38
+ * - 허용된 디렉토리 외부 접근 차단
39
+ *
40
+ * ⚠️ TOCTOU (Time-of-check to time-of-use) 주의:
41
+ * realpathSync() 호출과 실제 파일 작업 사이에 심볼릭 링크가 변경될 수 있음.
42
+ * 완전한 방지를 위해서는 chroot/namespace 격리를 권장.
43
+ */
44
+ export function isPathAllowed(targetPath, allowedPaths) {
45
+ try {
46
+ const resolved = path.resolve(targetPath);
47
+ // 위험한 파일 패턴 차단
48
+ if (DANGEROUS_PATTERNS.some((p) => p.test(resolved))) {
49
+ return false;
50
+ }
51
+ // 심볼릭 링크 해제하여 실제 경로 확인
52
+ let realPath;
53
+ try {
54
+ realPath = fsSync.realpathSync(resolved);
55
+ }
56
+ catch {
57
+ // 파일이 아직 없으면 (write_file) 부모 디렉토리 확인
58
+ const parentDir = path.dirname(resolved);
59
+ try {
60
+ realPath = path.join(fsSync.realpathSync(parentDir), path.basename(resolved));
61
+ }
62
+ catch {
63
+ // 부모 디렉토리도 resolve 실패 시 거부
64
+ return false;
65
+ }
66
+ }
67
+ const allowed = allowedPaths ?? getAllowedPaths();
68
+ // 정확한 경로 구분자로 비교 (startsWith만으로는 ~/DocumentsEvil 같은 경로 통과)
69
+ return allowed.some((allowedPath) => {
70
+ const normalizedAllowed = path.resolve(allowedPath);
71
+ return (realPath === normalizedAllowed ||
72
+ realPath.startsWith(normalizedAllowed + path.sep));
73
+ });
74
+ }
75
+ catch {
76
+ // 어떤 예외든 검증 실패로 처리 (fail-safe)
77
+ return false;
78
+ }
79
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "companionbot",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "AI 친구 텔레그램 봇 - Claude API 기반 개인화된 대화 상대",
5
5
  "keywords": [
6
6
  "telegram",
@@ -37,11 +37,14 @@
37
37
  "dev": "tsx src/cli/main.ts",
38
38
  "start": "node dist/cli/main.js",
39
39
  "build": "tsc",
40
+ "test": "vitest run",
41
+ "test:watch": "vitest",
40
42
  "setup": "tsx src/cli/setup.ts",
41
43
  "prepublishOnly": "npm run build"
42
44
  },
43
45
  "dependencies": {
44
46
  "@anthropic-ai/sdk": "^0.39.0",
47
+ "@grammyjs/ratelimiter": "^1.2.1",
45
48
  "cheerio": "^1.2.0",
46
49
  "googleapis": "^171.4.0",
47
50
  "grammy": "^1.31.0",
@@ -52,6 +55,7 @@
52
55
  "@types/node": "^22.0.0",
53
56
  "@types/node-cron": "^3.0.11",
54
57
  "tsx": "^4.0.0",
55
- "typescript": "^5.7.0"
58
+ "typescript": "^5.7.0",
59
+ "vitest": "^4.0.18"
56
60
  }
57
61
  }