oh-my-adhd 0.2.12 → 0.2.14

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.
@@ -11,6 +11,7 @@ const MANIFEST_LOCK_TTL_MS = 10000;
11
11
  const LOG_FILE = path.join(BRAIN_DIR, "logs", "brain.log");
12
12
  const VERSION_FILE = path.join(BRAIN_DIR, "VERSION");
13
13
  export const SCHEMA_VERSION = 1;
14
+ export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
14
15
  async function appendLog(level, msg) {
15
16
  try {
16
17
  const entry = `${new Date().toISOString()} [${level}] ${msg}\n`;
@@ -169,7 +170,7 @@ export async function saveCapture(content, threadId) {
169
170
  const captureId = randomUUID();
170
171
  const timestamp = new Date().toISOString();
171
172
  const tid = threadId ?? captureId;
172
- if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(tid)) {
173
+ if (!UUID_RE.test(tid)) {
173
174
  throw new Error(`Invalid threadId: ${tid}`);
174
175
  }
175
176
  const threadFile = path.join(THREADS_DIR, `${tid}.md`);
@@ -287,7 +288,7 @@ export async function getThreads() {
287
288
  return sorted;
288
289
  }
289
290
  export async function getThread(threadId) {
290
- if (!threadId || !/^[a-zA-Z0-9_-]+$/.test(threadId))
291
+ if (!threadId || !UUID_RE.test(threadId))
291
292
  return null;
292
293
  try {
293
294
  return await fs.readFile(path.join(THREADS_DIR, `${threadId}.md`), "utf-8");
@@ -351,7 +352,6 @@ export async function savePage(slug, content) {
351
352
  await fs.writeFile(tmpPage, content, "utf-8");
352
353
  await fs.rename(tmpPage, pageFile);
353
354
  }
354
- const UUID_RE = /^[a-zA-Z0-9_-]+$/;
355
355
  const TRASH_DIR = path.join(BRAIN_DIR, ".trash");
356
356
  export async function deleteThread(threadId) {
357
357
  if (!UUID_RE.test(threadId)) {
@@ -23,13 +23,30 @@ export function registerWikiExport(server) {
23
23
  const date = new Date().toISOString().slice(0, 10);
24
24
  const defaultPath = path.join(os.homedir(), `oh-my-adhd-export-${date}.json`);
25
25
  const resolved = outputPath ? path.resolve(outputPath) : defaultPath;
26
- // Require .json extension — prevents LLM-controlled path from clobbering arbitrary config files
26
+ // Require .json extension — prevents LLM-controlled path from clobbering non-JSON config files
27
27
  if (!resolved.endsWith(".json")) {
28
28
  return {
29
29
  content: [{ type: "text", text: "오류: outputPath는 .json 확장자로 끝나야 합니다." }],
30
30
  isError: true,
31
31
  };
32
32
  }
33
+ // Block writes into known sensitive dirs — use realpath for symlink safety
34
+ const SENSITIVE_DIRS = [".ssh", ".aws", ".gnupg", ".kube", ".docker",
35
+ path.join(".config", "git"), path.join(".config", "gh")];
36
+ const homeDir = os.homedir();
37
+ let realResolved = resolved;
38
+ try {
39
+ realResolved = await fs.realpath(path.dirname(resolved));
40
+ }
41
+ catch { /* dir may not exist yet */ }
42
+ const realHome = await fs.realpath(homeDir).catch(() => homeDir);
43
+ const relDir = path.relative(realHome, realResolved).toLowerCase();
44
+ if (SENSITIVE_DIRS.some(d => relDir === d.toLowerCase() || relDir.startsWith(d.toLowerCase() + path.sep))) {
45
+ return {
46
+ content: [{ type: "text", text: "오류: 보안상 해당 경로에는 내보낼 수 없습니다." }],
47
+ isError: true,
48
+ };
49
+ }
33
50
  const filePath = resolved;
34
51
  const tmp = filePath + ".tmp";
35
52
  await fs.writeFile(tmp, JSON.stringify(exportData, null, 2), "utf-8");
@@ -43,7 +60,7 @@ export function registerWikiExport(server) {
43
60
  `경로: ${filePath}`,
44
61
  `스레드: ${threads.length}개 | 페이지: ${pages.length}개 | ${sizeKB}KB`,
45
62
  "",
46
- "복원하려면: wiki_export 결과 JSON을 ~/.oh-my-adhd/로 수동 복사",
63
+ "복원하려면: wiki_import({ inputPath: \"<이 경로>\" }) 호출",
47
64
  ].join("\n"),
48
65
  }],
49
66
  };
@@ -1,10 +1,10 @@
1
1
  import { z } from "zod";
2
- import { ensureBrainDirs, BRAIN_DIR, SCHEMA_VERSION, withBrainLock } from "../../lib/brain.js";
2
+ import { ensureBrainDirs, BRAIN_DIR, SCHEMA_VERSION, UUID_RE, withBrainLock } from "../../lib/brain.js";
3
3
  import fs from "fs/promises";
4
4
  import path from "path";
5
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6
5
  const SLUG_RE = /^[a-z0-9가-힣][a-z0-9가-힣_-]{0,127}$/;
7
6
  const MAX_CONTENT_BYTES = 5 * 1024 * 1024; // 5MB per thread
7
+ const MAX_ITEMS = 10000; // max threads or pages per import
8
8
  export function registerWikiImport(server) {
9
9
  server.tool("wiki_import", "wiki_export로 내보낸 JSON 백업 파일을 가져온다. 기존 데이터와 병합(merge)하며 중복 thread ID는 덮어쓴다.", {
10
10
  inputPath: z.string().describe("가져올 .json 파일 경로 (wiki_export로 생성된 파일)"),
@@ -18,9 +18,15 @@ export function registerWikiImport(server) {
18
18
  isError: true,
19
19
  };
20
20
  }
21
- let raw;
21
+ // Check file size before reading into memory
22
22
  try {
23
- raw = await fs.readFile(resolved, "utf-8");
23
+ const stat = await fs.stat(resolved);
24
+ if (stat.size > 100 * 1024 * 1024) {
25
+ return {
26
+ content: [{ type: "text", text: "오류: 파일이 너무 큽니다 (100MB 초과)." }],
27
+ isError: true,
28
+ };
29
+ }
24
30
  }
25
31
  catch {
26
32
  return {
@@ -28,9 +34,13 @@ export function registerWikiImport(server) {
28
34
  isError: true,
29
35
  };
30
36
  }
31
- if (Buffer.byteLength(raw, "utf-8") > 100 * 1024 * 1024) {
37
+ let raw;
38
+ try {
39
+ raw = await fs.readFile(resolved, "utf-8");
40
+ }
41
+ catch {
32
42
  return {
33
- content: [{ type: "text", text: "오류: 파일이 너무 큽니다 (100MB 초과)." }],
43
+ content: [{ type: "text", text: `오류: 파일을 읽을 없습니다: ${resolved}` }],
34
44
  isError: true,
35
45
  };
36
46
  }
@@ -50,6 +60,12 @@ export function registerWikiImport(server) {
50
60
  isError: true,
51
61
  };
52
62
  }
63
+ if (exportData.threads.length > MAX_ITEMS || (exportData.pages?.length ?? 0) > MAX_ITEMS) {
64
+ return {
65
+ content: [{ type: "text", text: `오류: 항목이 너무 많습니다 (최대 ${MAX_ITEMS}개).` }],
66
+ isError: true,
67
+ };
68
+ }
53
69
  if (exportData.schemaVersion !== undefined && exportData.schemaVersion !== SCHEMA_VERSION) {
54
70
  return {
55
71
  content: [{ type: "text", text: `오류: 스키마 버전 불일치 (파일: ${exportData.schemaVersion}, 현재: ${SCHEMA_VERSION})` }],
@@ -122,24 +138,24 @@ export function registerWikiImport(server) {
122
138
  const tmp = manifestFile + ".tmp";
123
139
  await fs.writeFile(tmp, JSON.stringify(manifest, null, 2), "utf-8");
124
140
  await fs.rename(tmp, manifestFile);
125
- });
126
- // Import pages (not manifest-managed, no lock needed)
127
- if (exportData.pages && Array.isArray(exportData.pages)) {
128
- for (const rawPage of exportData.pages) {
129
- if (typeof rawPage !== "object" || rawPage === null)
130
- continue;
131
- const page = rawPage;
132
- const slug = typeof page.slug === "string" ? page.slug : "";
133
- const content = typeof page.content === "string" ? page.content : "";
134
- if (!SLUG_RE.test(slug) || !content)
135
- continue;
136
- const pageFile = path.join(pagesDir, `${slug}.md`);
137
- const tmp = pageFile + ".tmp";
138
- await fs.writeFile(tmp, content, "utf-8");
139
- await fs.rename(tmp, pageFile);
140
- importedPages++;
141
+ // Import pages inside lock for consistency with concurrent dump+import
142
+ if (exportData.pages && Array.isArray(exportData.pages)) {
143
+ for (const rawPage of exportData.pages) {
144
+ if (typeof rawPage !== "object" || rawPage === null)
145
+ continue;
146
+ const page = rawPage;
147
+ const slug = typeof page.slug === "string" ? page.slug : "";
148
+ const content = typeof page.content === "string" ? page.content : "";
149
+ if (!SLUG_RE.test(slug) || !content)
150
+ continue;
151
+ const pageFile = path.join(pagesDir, `${slug}.md`);
152
+ const pageTmp = pageFile + ".tmp";
153
+ await fs.writeFile(pageTmp, content, "utf-8");
154
+ await fs.rename(pageTmp, pageFile);
155
+ importedPages++;
156
+ }
141
157
  }
142
- }
158
+ });
143
159
  return {
144
160
  content: [{
145
161
  type: "text",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-adhd",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
4
4
  "description": "ADHD second brain — zero-friction capture, auto context restore, unstick. MCP-native Claude Code plugin.",
5
5
  "author": "Yeachan Heo",
6
6
  "repository": {
@@ -8,13 +8,15 @@ const BRAIN_DIR = process.env.OH_MY_ADHD_DIR ?? join(homedir(), ".oh-my-adhd");
8
8
  const MANIFEST = join(BRAIN_DIR, "threads", ".manifest.json");
9
9
 
10
10
  // Use parent PID (= Claude Code instance) as session discriminator — no singleton file needed
11
- const ppid = process.ppid ?? 0;
11
+ const ppid = process.ppid;
12
12
 
13
- // Write per-session start marker
14
- try {
15
- mkdirSync(BRAIN_DIR, { recursive: true });
16
- writeFileSync(join(BRAIN_DIR, `.session-start-${ppid}`), String(Date.now()));
17
- } catch { /* non-fatal */ }
13
+ // Write per-session start marker (skip if ppid is unavailable — avoids shared .session-start-0)
14
+ if (ppid) {
15
+ try {
16
+ mkdirSync(BRAIN_DIR, { recursive: true });
17
+ writeFileSync(join(BRAIN_DIR, `.session-start-${ppid}`), String(Date.now()));
18
+ } catch { /* non-fatal */ }
19
+ }
18
20
 
19
21
  // GC stale session files older than 24h (runs on every new session)
20
22
  try {
@@ -46,10 +48,12 @@ try {
46
48
  const openThreads = manifest.filter(t => t.is_open).slice(0, 4);
47
49
  if (openThreads.length === 0) process.exit(0);
48
50
 
51
+ // Strip control chars only — preserves emoji, Korean, CJK, etc.
49
52
  const sanitize = (s, max) => String(s ?? "")
50
- .replace(/[^ -~가-힣㄰-㆏ᄀ-ᇿ]/g, " ")
53
+ .replace(/[\x00-\x1F\x7F]/g, " ")
51
54
  .replace(/[`$<>]/g, "")
52
- .replace(/\bignore (all|previous)\b/gi, "[redacted]")
55
+ .replace(/\b(ignore|disregard)\s+(all|previous|prior)\b/gi, "[redacted]")
56
+ .replace(/(이전|앞의|위의|모든)\s*(지시|명령|규칙)\s*(무시|잊어|버려)/g, "[redacted]")
53
57
  .slice(0, max);
54
58
 
55
59
  const lines = [
@@ -11,7 +11,9 @@ const BRAIN_DIR = process.env.OH_MY_ADHD_DIR ?? join(homedir(), ".oh-my-adhd");
11
11
  const MANIFEST = join(BRAIN_DIR, "threads", ".manifest.json");
12
12
 
13
13
  // Use parent PID (= Claude Code instance) as session discriminator
14
- const ppid = process.ppid ?? 0;
14
+ const ppid = process.ppid;
15
+ // If ppid is unavailable, skip protection rather than risk a shared-file collision
16
+ if (!ppid) process.exit(0);
15
17
  const SESSION_START_FILE = join(BRAIN_DIR, `.session-start-${ppid}`);
16
18
  const LAST_DUMP_FILE = join(BRAIN_DIR, `.last-dump-${ppid}`);
17
19
 
@@ -36,10 +38,12 @@ try {
36
38
  const openThreads = manifest.filter(t => t.is_open);
37
39
  if (openThreads.length > 0) {
38
40
  const top = openThreads[0];
41
+ // Strip control chars only — preserves emoji, Korean, CJK, etc.
39
42
  const sanitize = (s, max) => String(s ?? "")
40
- .replace(/[^ -~가-힣㄰-㆏ᄀ-ᇿ]/g, " ")
43
+ .replace(/[\x00-\x1F\x7F]/g, " ")
41
44
  .replace(/[`$<>]/g, "")
42
- .replace(/\bignore (all|previous)\b/gi, "[redacted]")
45
+ .replace(/\b(ignore|disregard)\s+(all|previous|prior)\b/gi, "[redacted]")
46
+ .replace(/(이전|앞의|위의|모든)\s*(지시|명령|규칙)\s*(무시|잊어|버려)/g, "[redacted]")
43
47
  .slice(0, max);
44
48
  const title = sanitize(top.title ?? "진행중인 작업", 40);
45
49
  const nextHint = top.next_action ? `\n→ 다음할것: ${sanitize(top.next_action, 60)}` : "";