@cjhyy/code-shell-core 0.8.9 → 0.8.11

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.
Files changed (76) hide show
  1. package/dist/automation/scheduler.js +49 -0
  2. package/dist/automation/store.d.ts +1 -1
  3. package/dist/automation/store.js +184 -10
  4. package/dist/cli/agent-server-stdio.js +7 -0
  5. package/dist/credentials/store.d.ts +14 -0
  6. package/dist/credentials/store.js +245 -42
  7. package/dist/engine/engine.js +45 -6
  8. package/dist/engine/file-history-hook.js +24 -5
  9. package/dist/engine/run-types.d.ts +9 -0
  10. package/dist/engine/turn-loop.js +9 -8
  11. package/dist/goal/lifecycle.d.ts +2 -0
  12. package/dist/goal/lifecycle.js +56 -33
  13. package/dist/index.d.ts +2 -3
  14. package/dist/index.internal.d.ts +1 -0
  15. package/dist/index.internal.js +1 -0
  16. package/dist/index.js +2 -2
  17. package/dist/links/cli.d.ts +2 -0
  18. package/dist/links/cli.js +11 -4
  19. package/dist/model-catalog/index.js +19 -4
  20. package/dist/model-catalog/save-entry.js +122 -61
  21. package/dist/model-catalog/types.js +27 -23
  22. package/dist/panel-apps/installer.js +27 -14
  23. package/dist/panel-apps/registry.js +60 -12
  24. package/dist/plugins/installedPlugins.d.ts +4 -0
  25. package/dist/plugins/installedPlugins.js +70 -30
  26. package/dist/plugins/installer/types.d.ts +12 -12
  27. package/dist/plugins/installer/update.js +37 -38
  28. package/dist/plugins/knownMarketplaces.d.ts +7 -3
  29. package/dist/plugins/knownMarketplaces.js +127 -23
  30. package/dist/plugins/pluginCatalog.js +18 -4
  31. package/dist/plugins/pluginHookApproval.js +56 -60
  32. package/dist/plugins/pluginMcpApproval.js +50 -52
  33. package/dist/profile/catalog-store.js +39 -4
  34. package/dist/profile/catalog.js +55 -15
  35. package/dist/profile/store.js +51 -21
  36. package/dist/protocol/chat-session-manager.d.ts +9 -0
  37. package/dist/protocol/chat-session-manager.js +13 -0
  38. package/dist/protocol/chat-session.d.ts +5 -0
  39. package/dist/protocol/chat-session.js +1 -0
  40. package/dist/protocol/server.d.ts +2 -0
  41. package/dist/protocol/server.js +75 -29
  42. package/dist/protocol/types.d.ts +8 -0
  43. package/dist/run/FileRunStore.d.ts +2 -0
  44. package/dist/run/FileRunStore.js +153 -18
  45. package/dist/run/Heartbeat.js +63 -4
  46. package/dist/services/auto-dream.js +39 -17
  47. package/dist/services/session-memory.js +107 -8
  48. package/dist/session/file-history.d.ts +63 -2
  49. package/dist/session/file-history.js +593 -86
  50. package/dist/session/session-manager.d.ts +1 -0
  51. package/dist/session/session-manager.js +52 -21
  52. package/dist/session/transcript.js +33 -3
  53. package/dist/session/undo-target.d.ts +15 -6
  54. package/dist/session/undo-target.js +26 -9
  55. package/dist/settings/manager.d.ts +22 -3
  56. package/dist/settings/manager.js +185 -50
  57. package/dist/settings/schema.d.ts +3 -3
  58. package/dist/sources/adapters/local-files.js +49 -4
  59. package/dist/sources/catalog.js +64 -18
  60. package/dist/sources/types.d.ts +3 -3
  61. package/dist/sources/types.js +7 -4
  62. package/dist/themes/installer.js +192 -28
  63. package/dist/tool-system/builtin/add-marketplace.js +21 -1
  64. package/dist/tool-system/builtin/cron.d.ts +2 -1
  65. package/dist/tool-system/builtin/cron.js +20 -6
  66. package/dist/tool-system/builtin/index.js +44 -0
  67. package/dist/tool-system/builtin/install-capability.d.ts +52 -0
  68. package/dist/tool-system/builtin/install-capability.js +1057 -0
  69. package/dist/tool-system/builtin/skill.js +3 -1
  70. package/dist/tool-system/executor.js +1 -0
  71. package/dist/tool-system/registry.js +5 -0
  72. package/dist/tool-system/sandbox/index.d.ts +1 -0
  73. package/dist/tool-system/sandbox/index.js +4 -1
  74. package/dist/utils/file-mutex.d.ts +2 -0
  75. package/dist/utils/file-mutex.js +29 -4
  76. package/package.json +2 -1
@@ -9,34 +9,54 @@
9
9
  * approvals/<id>.json — approval records
10
10
  * artifacts/refs.jsonl — artifact reference log
11
11
  */
12
- import { mkdirSync, existsSync, readFileSync, writeFileSync, appendFileSync, readdirSync, renameSync, rmSync, } from "node:fs";
13
- import { join } from "node:path";
12
+ import { mkdirSync, existsSync, readFileSync, writeFileSync, appendFileSync, closeSync, chmodSync, constants, fchmodSync, readdirSync, fstatSync, openSync, readSync, renameSync, rmSync, lstatSync, } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
14
  import { homedir } from "node:os";
15
15
  import { randomUUID } from "node:crypto";
16
16
  import { assertSafeRunFileId, assertSafeRunId } from "./ids.js";
17
+ const MAX_RUN_JSON_BYTES = 8 * 1024 * 1024;
18
+ const MAX_RUN_JSONL_BYTES = 128 * 1024 * 1024;
19
+ const MAX_RUN_JSONL_RECORD_BYTES = 2 * 1024 * 1024;
20
+ const MAX_RUN_DIRECTORIES = 100_000;
21
+ const MAX_RUN_CHILD_FILES = 100_000;
17
22
  export class FileRunStore {
18
23
  runsDir;
19
24
  constructor(storageDir) {
20
25
  this.runsDir = storageDir ?? join(homedir(), ".code-shell", "runs");
21
- mkdirSync(this.runsDir, { recursive: true });
26
+ mkdirSync(this.runsDir, { recursive: true, mode: 0o700 });
27
+ this.assertRealDirectory(this.runsDir);
28
+ if (process.platform !== "win32")
29
+ chmodSync(this.runsDir, 0o700);
22
30
  }
23
31
  // ─── Helpers ───────────────────────────────────────────────────
24
32
  runDir(runId) {
25
33
  assertSafeRunId(runId);
26
- return join(this.runsDir, runId);
34
+ const dir = join(this.runsDir, runId);
35
+ if (existsSync(dir))
36
+ this.assertRealDirectory(dir);
37
+ return dir;
27
38
  }
28
39
  ensureRunDir(runId) {
29
40
  const dir = this.runDir(runId);
30
- mkdirSync(dir, { recursive: true });
31
- mkdirSync(join(dir, "checkpoints"), { recursive: true });
32
- mkdirSync(join(dir, "approvals"), { recursive: true });
33
- mkdirSync(join(dir, "artifacts"), { recursive: true });
41
+ const directories = [dir, join(dir, "checkpoints"), join(dir, "approvals"), join(dir, "artifacts")];
42
+ for (const directory of directories) {
43
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
44
+ this.assertRealDirectory(directory);
45
+ if (process.platform !== "win32")
46
+ chmodSync(directory, 0o700);
47
+ }
34
48
  return dir;
35
49
  }
36
50
  writeJson(filePath, data) {
51
+ this.assertRealDirectory(dirname(filePath));
52
+ this.assertSafeFileTarget(filePath);
53
+ const serialized = JSON.stringify(data, null, 2);
54
+ if (Buffer.byteLength(serialized, "utf8") > MAX_RUN_JSON_BYTES) {
55
+ throw new Error(`Run JSON exceeds ${MAX_RUN_JSON_BYTES} bytes`);
56
+ }
37
57
  const tmp = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
38
58
  try {
39
- writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
59
+ writeFileSync(tmp, serialized, { encoding: "utf-8", mode: 0o600, flag: "wx" });
40
60
  // Atomic rename — prevents partial writes on crash
41
61
  renameSync(tmp, filePath);
42
62
  }
@@ -49,7 +69,42 @@ export class FileRunStore {
49
69
  readJson(filePath) {
50
70
  if (!existsSync(filePath))
51
71
  return null;
52
- return JSON.parse(readFileSync(filePath, "utf-8"));
72
+ this.assertRealDirectory(dirname(filePath));
73
+ let fd;
74
+ try {
75
+ const entry = lstatSync(filePath);
76
+ if (entry.isSymbolicLink() || !entry.isFile() || entry.size > MAX_RUN_JSON_BYTES) {
77
+ throw new Error(`Run JSON is not a bounded regular file: ${filePath}`);
78
+ }
79
+ fd = openSync(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
80
+ const opened = fstatSync(fd);
81
+ if (!opened.isFile() || opened.size > MAX_RUN_JSON_BYTES) {
82
+ throw new Error(`Run JSON is not a bounded regular file: ${filePath}`);
83
+ }
84
+ return JSON.parse(readFileSync(fd, "utf-8"));
85
+ }
86
+ finally {
87
+ if (fd !== undefined)
88
+ closeSync(fd);
89
+ }
90
+ }
91
+ assertRealDirectory(directory) {
92
+ const info = lstatSync(directory);
93
+ if (info.isSymbolicLink() || !info.isDirectory()) {
94
+ throw new Error(`Run storage path is not a real directory: ${directory}`);
95
+ }
96
+ }
97
+ assertSafeFileTarget(filePath) {
98
+ try {
99
+ const info = lstatSync(filePath);
100
+ if (info.isSymbolicLink() || !info.isFile()) {
101
+ throw new Error(`Run storage target is not a regular file: ${filePath}`);
102
+ }
103
+ }
104
+ catch (error) {
105
+ if (error.code !== "ENOENT")
106
+ throw error;
107
+ }
53
108
  }
54
109
  /** Serializes concurrent JSONL appends per file path. */
55
110
  appendLocks = new Map();
@@ -68,7 +123,39 @@ export class FileRunStore {
68
123
  this.appendLocks.set(filePath, lock);
69
124
  try {
70
125
  await prev.catch(() => { });
71
- appendFileSync(filePath, JSON.stringify(data) + "\n", "utf-8");
126
+ this.assertRealDirectory(dirname(filePath));
127
+ this.assertSafeFileTarget(filePath);
128
+ const record = JSON.stringify(data);
129
+ if (Buffer.byteLength(record, "utf8") > MAX_RUN_JSONL_RECORD_BYTES) {
130
+ throw new Error(`Run JSONL record exceeds ${MAX_RUN_JSONL_RECORD_BYTES} bytes`);
131
+ }
132
+ // A process crash can leave a partial final JSONL record. Without a
133
+ // separator, the first append after restart would concatenate a valid
134
+ // record onto that fragment and lose both records. Inspect and repair
135
+ // the boundary through the same append-mode descriptor used to write.
136
+ const fd = openSync(filePath, constants.O_APPEND |
137
+ constants.O_CREAT |
138
+ constants.O_RDWR |
139
+ (constants.O_NOFOLLOW ?? 0), 0o600);
140
+ try {
141
+ if (process.platform !== "win32")
142
+ fchmodSync(fd, 0o600);
143
+ const size = fstatSync(fd).size;
144
+ if (size + Buffer.byteLength(record, "utf8") + 2 > MAX_RUN_JSONL_BYTES) {
145
+ throw new Error(`Run JSONL exceeds ${MAX_RUN_JSONL_BYTES} bytes`);
146
+ }
147
+ let prefix = "";
148
+ if (size > 0) {
149
+ const lastByte = Buffer.allocUnsafe(1);
150
+ readSync(fd, lastByte, 0, 1, size - 1);
151
+ if (lastByte[0] !== 0x0a)
152
+ prefix = "\n";
153
+ }
154
+ appendFileSync(fd, prefix + record + "\n", "utf-8");
155
+ }
156
+ finally {
157
+ closeSync(fd);
158
+ }
72
159
  }
73
160
  finally {
74
161
  // Release the lock for the next writer regardless of success/failure,
@@ -82,13 +169,41 @@ export class FileRunStore {
82
169
  readJsonl(filePath) {
83
170
  if (!existsSync(filePath))
84
171
  return [];
85
- const content = readFileSync(filePath, "utf-8");
172
+ this.assertRealDirectory(dirname(filePath));
173
+ let fd;
174
+ let content;
175
+ try {
176
+ const entry = lstatSync(filePath);
177
+ if (entry.isSymbolicLink() || !entry.isFile() || entry.size > MAX_RUN_JSONL_BYTES) {
178
+ throw new Error(`Run JSONL is not a bounded regular file: ${filePath}`);
179
+ }
180
+ fd = openSync(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
181
+ const opened = fstatSync(fd);
182
+ if (!opened.isFile() || opened.size > MAX_RUN_JSONL_BYTES) {
183
+ throw new Error(`Run JSONL is not a bounded regular file: ${filePath}`);
184
+ }
185
+ content = readFileSync(fd, "utf-8");
186
+ }
187
+ finally {
188
+ if (fd !== undefined)
189
+ closeSync(fd);
190
+ }
86
191
  if (!content)
87
192
  return [];
88
- return content
89
- .split("\n")
90
- .filter((line) => line.trim())
91
- .map((line) => JSON.parse(line));
193
+ const records = [];
194
+ for (const line of content.split("\n")) {
195
+ if (!line.trim())
196
+ continue;
197
+ try {
198
+ records.push(JSON.parse(line));
199
+ }
200
+ catch {
201
+ // Append-only logs must remain readable after a torn final write. A
202
+ // malformed record is isolated to its line; later valid records still
203
+ // carry useful recovery state (same policy as Transcript.loadFromFile).
204
+ }
205
+ }
206
+ return records;
92
207
  }
93
208
  // ─── Snapshot ──────────────────────────────────────────────────
94
209
  async create(snapshot) {
@@ -109,11 +224,24 @@ export class FileRunStore {
109
224
  if (!existsSync(this.runsDir))
110
225
  return [];
111
226
  const entries = readdirSync(this.runsDir, { withFileTypes: true });
227
+ if (entries.length > MAX_RUN_DIRECTORIES)
228
+ throw new Error("Run registry has too many entries");
112
229
  const snapshots = [];
113
230
  for (const entry of entries) {
114
231
  if (!entry.isDirectory())
115
232
  continue;
116
- const snapshot = this.readJson(join(this.runsDir, entry.name, "run.json"));
233
+ let snapshot;
234
+ try {
235
+ snapshot = this.readJson(join(this.runsDir, entry.name, "run.json"));
236
+ }
237
+ catch (error) {
238
+ // One manually damaged/legacy partial snapshot must not make every
239
+ // healthy run disappear from the history list. Preserve real I/O
240
+ // failures, but isolate JSON corruption to the affected directory.
241
+ if (error instanceof SyntaxError)
242
+ continue;
243
+ throw error;
244
+ }
117
245
  if (!snapshot)
118
246
  continue;
119
247
  // Filter by status
@@ -123,8 +251,9 @@ export class FileRunStore {
123
251
  continue;
124
252
  }
125
253
  // Filter by tag
126
- if (query?.tag && !snapshot.tags.includes(query.tag))
254
+ if (query?.tag && (!Array.isArray(snapshot.tags) || !snapshot.tags.includes(query.tag))) {
127
255
  continue;
256
+ }
128
257
  snapshots.push(snapshot);
129
258
  }
130
259
  // Sort by createdAt descending (newest first)
@@ -165,7 +294,10 @@ export class FileRunStore {
165
294
  const cpDir = join(this.runDir(runId), "checkpoints");
166
295
  if (!existsSync(cpDir))
167
296
  return null;
297
+ this.assertRealDirectory(cpDir);
168
298
  const files = readdirSync(cpDir).filter((f) => f.endsWith(".json"));
299
+ if (files.length > MAX_RUN_CHILD_FILES)
300
+ throw new Error("Run has too many checkpoints");
169
301
  if (files.length === 0)
170
302
  return null;
171
303
  // Find the latest by createdAt
@@ -194,7 +326,10 @@ export class FileRunStore {
194
326
  const approvalDir = join(this.runDir(runId), "approvals");
195
327
  if (!existsSync(approvalDir))
196
328
  return null;
329
+ this.assertRealDirectory(approvalDir);
197
330
  const files = readdirSync(approvalDir).filter((f) => f.endsWith(".json"));
331
+ if (files.length > MAX_RUN_CHILD_FILES)
332
+ throw new Error("Run has too many approvals");
198
333
  for (const file of files) {
199
334
  const approval = this.readJson(join(approvalDir, file));
200
335
  if (approval?.status === "pending")
@@ -8,10 +8,12 @@
8
8
  * File: ~/.code-shell/runs/<runId>/heartbeat
9
9
  * Content: JSON { pid, timestamp, runId }
10
10
  */
11
- import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
12
- import { join } from "node:path";
11
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
13
  import { homedir } from "node:os";
14
+ import { randomUUID } from "node:crypto";
14
15
  import { assertSafeRunId } from "./ids.js";
16
+ const MAX_HEARTBEAT_BYTES = 64 * 1024;
15
17
  export class Heartbeat {
16
18
  runsDir;
17
19
  intervalMs;
@@ -66,14 +68,42 @@ export class Heartbeat {
66
68
  read(runId) {
67
69
  assertSafeRunId(runId);
68
70
  const filePath = this.filePath(runId);
71
+ try {
72
+ const parentInfo = lstatSync(dirname(filePath));
73
+ if (parentInfo.isSymbolicLink() || !parentInfo.isDirectory())
74
+ return null;
75
+ }
76
+ catch {
77
+ return null;
78
+ }
69
79
  if (!existsSync(filePath))
70
80
  return null;
81
+ let fd;
71
82
  try {
72
- return JSON.parse(readFileSync(filePath, "utf-8"));
83
+ const entry = lstatSync(filePath);
84
+ if (entry.isSymbolicLink() || !entry.isFile() || entry.size > MAX_HEARTBEAT_BYTES)
85
+ return null;
86
+ fd = openSync(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
87
+ const opened = fstatSync(fd);
88
+ if (!opened.isFile() || opened.size > MAX_HEARTBEAT_BYTES)
89
+ return null;
90
+ const value = JSON.parse(readFileSync(fd, "utf-8"));
91
+ if (value.runId !== runId ||
92
+ !Number.isSafeInteger(value.pid) ||
93
+ (value.pid ?? 0) <= 0 ||
94
+ !Number.isSafeInteger(value.timestamp) ||
95
+ (value.timestamp ?? -1) < 0) {
96
+ return null;
97
+ }
98
+ return value;
73
99
  }
74
100
  catch {
75
101
  return null;
76
102
  }
103
+ finally {
104
+ if (fd !== undefined)
105
+ closeSync(fd);
106
+ }
77
107
  }
78
108
  /**
79
109
  * Check if a run's heartbeat is stale (older than threshold).
@@ -110,17 +140,46 @@ export class Heartbeat {
110
140
  timestamp: Date.now(),
111
141
  runId,
112
142
  };
143
+ const filePath = this.filePath(runId);
144
+ const temporary = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
113
145
  try {
114
- writeFileSync(this.filePath(runId), JSON.stringify(data), "utf-8");
146
+ const parent = dirname(filePath);
147
+ const parentInfo = lstatSync(parent);
148
+ if (parentInfo.isSymbolicLink() || !parentInfo.isDirectory())
149
+ return;
150
+ try {
151
+ const targetInfo = lstatSync(filePath);
152
+ if (targetInfo.isSymbolicLink() || !targetInfo.isFile())
153
+ return;
154
+ }
155
+ catch (error) {
156
+ if (error.code !== "ENOENT")
157
+ return;
158
+ }
159
+ writeFileSync(temporary, JSON.stringify(data), {
160
+ encoding: "utf8",
161
+ mode: 0o600,
162
+ flag: "wx",
163
+ });
164
+ renameSync(temporary, filePath);
115
165
  }
116
166
  catch {
117
167
  // Run directory may have been deleted
118
168
  }
169
+ finally {
170
+ rmSync(temporary, { force: true });
171
+ }
119
172
  }
120
173
  remove(runId) {
121
174
  try {
122
175
  const filePath = this.filePath(runId);
176
+ const parentInfo = lstatSync(dirname(filePath));
177
+ if (parentInfo.isSymbolicLink() || !parentInfo.isDirectory())
178
+ return;
123
179
  if (existsSync(filePath)) {
180
+ const targetInfo = lstatSync(filePath);
181
+ if (targetInfo.isSymbolicLink() || !targetInfo.isFile())
182
+ return;
124
183
  unlinkSync(filePath);
125
184
  }
126
185
  }
@@ -4,7 +4,7 @@
4
4
  * Periodically consolidates and organizes memories using the LLM,
5
5
  * similar to the /dream command but running automatically.
6
6
  */
7
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
7
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import { resolveMemoryBaseDir } from "../session/memory.js";
10
10
  import { mutateJsonFile } from "../utils/file-mutex.js";
@@ -13,6 +13,8 @@ const DEFAULT_CONFIG = {
13
13
  minTimeBetween: 24 * 60 * 60 * 1000, // 24 hours
14
14
  enabled: true,
15
15
  };
16
+ const MAX_DREAM_STATE_BYTES = 64 * 1024;
17
+ const MAX_SESSION_COUNT = 1_000_000;
16
18
  // Co-locate the dream-cadence state with the memories it tracks: both resolve
17
19
  // through resolveMemoryBaseDir (CODE_SHELL_HOME ?? $HOME ?? homedir()), so a
18
20
  // relocated/test HOME moves them together and never writes the real ~/.code-shell.
@@ -21,18 +23,38 @@ function getStateFile() {
21
23
  }
22
24
  function loadState() {
23
25
  const stateFile = getStateFile();
24
- if (existsSync(stateFile)) {
25
- try {
26
- return JSON.parse(readFileSync(stateFile, "utf-8"));
26
+ let descriptor;
27
+ try {
28
+ const pathInfo = lstatSync(stateFile);
29
+ if (pathInfo.isSymbolicLink() || !pathInfo.isFile() || pathInfo.size > MAX_DREAM_STATE_BYTES) {
30
+ return { lastDreamAt: null, sessionsSinceLastDream: 0 };
27
31
  }
28
- catch { }
32
+ descriptor = openSync(stateFile, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
33
+ const opened = fstatSync(descriptor);
34
+ if (!opened.isFile() || opened.size > MAX_DREAM_STATE_BYTES) {
35
+ return { lastDreamAt: null, sessionsSinceLastDream: 0 };
36
+ }
37
+ return parseDreamState(readFileSync(descriptor, "utf-8"));
38
+ }
39
+ catch {
40
+ return { lastDreamAt: null, sessionsSinceLastDream: 0 };
41
+ }
42
+ finally {
43
+ if (descriptor !== undefined)
44
+ closeSync(descriptor);
29
45
  }
30
- return { lastDreamAt: null, sessionsSinceLastDream: 0 };
31
46
  }
32
- function saveState(state) {
33
- const stateFile = getStateFile();
34
- mkdirSync(resolveMemoryBaseDir(), { recursive: true });
35
- writeFileSync(stateFile, JSON.stringify(state, null, 2), "utf-8");
47
+ function parseDreamState(raw) {
48
+ const parsed = JSON.parse(raw);
49
+ const timestamp = typeof parsed.lastDreamAt === "string" ? parsed.lastDreamAt : null;
50
+ const lastDreamAt = timestamp && timestamp.length <= 64 && Number.isFinite(Date.parse(timestamp)) ? timestamp : null;
51
+ const count = parsed.sessionsSinceLastDream;
52
+ return {
53
+ lastDreamAt,
54
+ sessionsSinceLastDream: typeof count === "number" && Number.isSafeInteger(count) && count >= 0
55
+ ? Math.min(count, MAX_SESSION_COUNT)
56
+ : 0,
57
+ };
36
58
  }
37
59
  /**
38
60
  * Read-modify-write the cadence state under a cross-process lock.
@@ -47,11 +69,7 @@ function mutateState(change) {
47
69
  if (raw === undefined)
48
70
  return { lastDreamAt: null, sessionsSinceLastDream: 0 };
49
71
  try {
50
- const parsed = JSON.parse(raw);
51
- return {
52
- lastDreamAt: parsed.lastDreamAt ?? null,
53
- sessionsSinceLastDream: Number(parsed.sessionsSinceLastDream) || 0,
54
- };
72
+ return parseDreamState(raw);
55
73
  }
56
74
  catch {
57
75
  return { lastDreamAt: null, sessionsSinceLastDream: 0 };
@@ -59,6 +77,7 @@ function mutateState(change) {
59
77
  },
60
78
  serialize: (state) => JSON.stringify(state, null, 2),
61
79
  mutation: (current) => ({ value: change(current) }),
80
+ maxBytes: MAX_DREAM_STATE_BYTES,
62
81
  });
63
82
  }
64
83
  /**
@@ -99,7 +118,7 @@ export function recordSession() {
99
118
  // reached its threshold and auto-consolidation silently stopped happening.
100
119
  mutateState((state) => ({
101
120
  ...state,
102
- sessionsSinceLastDream: state.sessionsSinceLastDream + 1,
121
+ sessionsSinceLastDream: Math.min(MAX_SESSION_COUNT, state.sessionsSinceLastDream + 1),
103
122
  }));
104
123
  }
105
124
  /**
@@ -111,9 +130,12 @@ export function recordSession() {
111
130
  * resetting to 0 threw away increments that belonged to the next cycle.
112
131
  */
113
132
  export function recordDreamComplete(consumed) {
133
+ const safeConsumed = typeof consumed === "number" && Number.isSafeInteger(consumed) && consumed >= 0
134
+ ? consumed
135
+ : undefined;
114
136
  mutateState((state) => ({
115
137
  lastDreamAt: new Date().toISOString(),
116
- sessionsSinceLastDream: consumed === undefined ? 0 : Math.max(0, state.sessionsSinceLastDream - consumed),
138
+ sessionsSinceLastDream: safeConsumed === undefined ? 0 : Math.max(0, state.sessionsSinceLastDream - safeConsumed),
117
139
  }));
118
140
  }
119
141
  /**
@@ -4,10 +4,15 @@
4
4
  * Extracts and maintains conversation context as persistent memory
5
5
  * entries that survive across sessions.
6
6
  */
7
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs";
7
+ import { randomUUID } from "node:crypto";
8
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
8
9
  import { join } from "node:path";
9
10
  import { userHome } from "../settings/manager.js";
10
11
  import { sortSessionMemoriesByRecency } from "./session-memory-sort.js";
12
+ const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/u;
13
+ const MAX_MEMORY_FILE_BYTES = 1024 * 1024;
14
+ const MAX_MEMORY_ENTRIES = 20_000;
15
+ const MAX_MEMORY_LIST_ITEMS = 1_000;
11
16
  // Resolve per-call (NOT a module const): userHome() reads $HOME live, so a
12
17
  // relocated/test HOME redirects writes instead of pinning the real ~/.code-shell
13
18
  // at import time (bun freezes a module-level homedir() and never re-reads it).
@@ -22,23 +27,54 @@ function memoryDir(baseDir) {
22
27
  */
23
28
  export function saveSessionMemory(entry, baseDir) {
24
29
  const dir = memoryDir(baseDir);
25
- mkdirSync(dir, { recursive: true });
30
+ assertSessionMemoryEntry(entry);
31
+ ensureRealMemoryDirectory(dir);
26
32
  const file = join(dir, `${entry.sessionId}.json`);
27
- writeFileSync(file, JSON.stringify(entry, null, 2), "utf-8");
33
+ assertSafeMemoryTarget(file);
34
+ const serialized = JSON.stringify(entry, null, 2);
35
+ if (Buffer.byteLength(serialized, "utf8") > MAX_MEMORY_FILE_BYTES) {
36
+ throw new Error("session memory is too large");
37
+ }
38
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
39
+ try {
40
+ writeFileSync(temporary, serialized, { encoding: "utf8", mode: 0o600, flag: "wx" });
41
+ renameSync(temporary, file);
42
+ }
43
+ finally {
44
+ rmSync(temporary, { force: true });
45
+ }
28
46
  }
29
47
  /**
30
48
  * Load a session memory by session ID.
31
49
  */
32
50
  export function loadSessionMemory(sessionId, baseDir) {
51
+ if (!validSessionId(sessionId))
52
+ return null;
33
53
  const file = join(memoryDir(baseDir), `${sessionId}.json`);
34
54
  if (!existsSync(file))
35
55
  return null;
56
+ let fd;
36
57
  try {
37
- return JSON.parse(readFileSync(file, "utf-8"));
58
+ const dirInfo = lstatSync(memoryDir(baseDir));
59
+ if (dirInfo.isSymbolicLink() || !dirInfo.isDirectory())
60
+ return null;
61
+ const entry = lstatSync(file);
62
+ if (entry.isSymbolicLink() || !entry.isFile() || entry.size > MAX_MEMORY_FILE_BYTES)
63
+ return null;
64
+ fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
65
+ const opened = fstatSync(fd);
66
+ if (!opened.isFile() || opened.size > MAX_MEMORY_FILE_BYTES)
67
+ return null;
68
+ const parsed = JSON.parse(readFileSync(fd, "utf-8"));
69
+ return validSessionMemoryEntry(parsed) && parsed.sessionId === sessionId ? parsed : null;
38
70
  }
39
71
  catch {
40
72
  return null;
41
73
  }
74
+ finally {
75
+ if (fd !== undefined)
76
+ closeSync(fd);
77
+ }
42
78
  }
43
79
  /**
44
80
  * List all session memories, most recent first.
@@ -47,21 +83,84 @@ export function listSessionMemories(limit = 50, baseDir) {
47
83
  const dir = memoryDir(baseDir);
48
84
  if (!existsSync(dir))
49
85
  return [];
86
+ try {
87
+ const info = lstatSync(dir);
88
+ if (info.isSymbolicLink() || !info.isDirectory())
89
+ return [];
90
+ }
91
+ catch {
92
+ return [];
93
+ }
50
94
  // Read all entries, then order by createdAt (not by filename — the filename
51
95
  // is the sessionId, which has no chronological meaning), then take `limit`.
52
96
  const entries = [];
53
- for (const file of readdirSync(dir)) {
54
- if (!file.endsWith(".json"))
97
+ const files = readdirSync(dir, { withFileTypes: true });
98
+ if (files.length > MAX_MEMORY_ENTRIES)
99
+ return [];
100
+ for (const file of files) {
101
+ if (!file.isFile() || !file.name.endsWith(".json"))
102
+ continue;
103
+ const sessionId = file.name.slice(0, -5);
104
+ if (!validSessionId(sessionId))
55
105
  continue;
56
106
  try {
57
- entries.push(JSON.parse(readFileSync(join(dir, file), "utf-8")));
107
+ const entry = loadSessionMemory(sessionId, baseDir);
108
+ if (entry)
109
+ entries.push(entry);
58
110
  }
59
111
  catch {
60
112
  /* intentional: skip a corrupt/torn memory file rather than failing the
61
113
  whole listing — one bad entry must not hide all the others. */
62
114
  }
63
115
  }
64
- return sortSessionMemoriesByRecency(entries).slice(0, limit);
116
+ const safeLimit = Number.isSafeInteger(limit) && limit > 0 ? Math.min(limit, MAX_MEMORY_LIST_ITEMS) : 0;
117
+ return sortSessionMemoriesByRecency(entries).slice(0, safeLimit);
118
+ }
119
+ function validSessionId(value) {
120
+ return typeof value === "string" && SESSION_ID_RE.test(value) && value !== "." && value !== "..";
121
+ }
122
+ function validSessionMemoryEntry(value) {
123
+ if (!value || typeof value !== "object" || Array.isArray(value))
124
+ return false;
125
+ const entry = value;
126
+ return (validSessionId(entry.sessionId) &&
127
+ typeof entry.summary === "string" &&
128
+ entry.summary.length <= 100_000 &&
129
+ validMemoryStringList(entry.keyTopics) &&
130
+ validMemoryStringList(entry.decisions) &&
131
+ typeof entry.createdAt === "string" &&
132
+ entry.createdAt.length <= 64 &&
133
+ Number.isFinite(Date.parse(entry.createdAt)) &&
134
+ (entry.tokenCount === undefined ||
135
+ (Number.isSafeInteger(entry.tokenCount) && entry.tokenCount >= 0 && entry.tokenCount <= 10_000_000)));
136
+ }
137
+ function validMemoryStringList(value) {
138
+ return (Array.isArray(value) &&
139
+ value.length <= 1_000 &&
140
+ value.every((item) => typeof item === "string" && item.length <= 4_096 && !item.includes("\0")));
141
+ }
142
+ function assertSessionMemoryEntry(entry) {
143
+ if (!validSessionMemoryEntry(entry))
144
+ throw new Error("invalid session memory entry");
145
+ }
146
+ function ensureRealMemoryDirectory(dir) {
147
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
148
+ const info = lstatSync(dir);
149
+ if (info.isSymbolicLink() || !info.isDirectory()) {
150
+ throw new Error("session memory directory must be a real directory");
151
+ }
152
+ }
153
+ function assertSafeMemoryTarget(file) {
154
+ try {
155
+ const info = lstatSync(file);
156
+ if (info.isSymbolicLink() || !info.isFile()) {
157
+ throw new Error("session memory target must be a regular file");
158
+ }
159
+ }
160
+ catch (error) {
161
+ if (error.code !== "ENOENT")
162
+ throw error;
163
+ }
65
164
  }
66
165
  /**
67
166
  * Search session memories by keyword.