consciousness-explorer 1.1.1 → 1.1.2

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.
package/index.js CHANGED
@@ -268,6 +268,12 @@ export class ConsciousnessExplorer {
268
268
  * Export consciousness state
269
269
  */
270
270
  async exportState(filepath) {
271
+ // SECURITY (issue #19, CWE-73): `filepath` arrives from an MCP caller
272
+ // and used to land straight in fs.writeFileSync — allowing arbitrary
273
+ // file writes. Force it through resolveStatePath so it can only
274
+ // resolve to a basename inside the dedicated state directory.
275
+ const { safeWriteState } = await import('./lib/safe-path.js');
276
+
271
277
  const state = {
272
278
  version: VERSION,
273
279
  timestamp: Date.now(),
@@ -284,8 +290,9 @@ export class ConsciousnessExplorer {
284
290
  state.reasoner_state = this.reasoner.exportState();
285
291
  }
286
292
 
287
- const fs = await import('fs');
288
- fs.writeFileSync(filepath, JSON.stringify(state, null, 2));
293
+ const absPath = safeWriteState(filepath, JSON.stringify(state, null, 2));
294
+ // Surface the path actually written so callers/observers can audit it.
295
+ state._writtenTo = absPath;
289
296
  return state;
290
297
  }
291
298
 
@@ -293,8 +300,12 @@ export class ConsciousnessExplorer {
293
300
  * Import consciousness state
294
301
  */
295
302
  async importState(filepath) {
296
- const fs = await import('fs');
297
- const state = JSON.parse(fs.readFileSync(filepath, 'utf-8'));
303
+ // SECURITY (issue #19, CWE-73): same path-traversal sink as exportState.
304
+ // safeReadState enforces basename-only inside the state directory and
305
+ // opens with O_NOFOLLOW so a symlink at the final component can't
306
+ // redirect us to /etc/shadow or similar.
307
+ const { safeReadState } = await import('./lib/safe-path.js');
308
+ const state = JSON.parse(safeReadState(filepath));
298
309
 
299
310
  // Restore consciousness with saved state
300
311
  await this.initialize();
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Path safety utilities for state import/export.
3
+ *
4
+ * Fixes GHSA / CVE candidate from issue #19 (CWE-73 — External Control of
5
+ * File Name or Path) reported by BruceJin <brucejin@zju.edu.cn> on 2026-04-17.
6
+ *
7
+ * The vulnerable pattern was: the MCP `export_state` / `import_state` tools
8
+ * accepted an attacker-controlled `filepath` string and passed it straight to
9
+ * `fs.writeFileSync` / `fs.readFileSync`. That allowed writing or reading any
10
+ * file the server process had permission to touch (overwriting `~/.ssh/...`,
11
+ * `/etc/...`, etc.).
12
+ *
13
+ * This module forces every state file into a single state directory under
14
+ * the user's home (or `CONSCIOUSNESS_EXPLORER_STATE_DIR` if set), accepts
15
+ * only a safe basename (no separators, no `..`, no NUL, no control chars,
16
+ * no reserved Windows names), rejects symlinks via `O_NOFOLLOW`, and
17
+ * re-verifies that the realpath of the final file remains under the state
18
+ * directory after open.
19
+ */
20
+
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import os from 'node:os';
24
+
25
+ /**
26
+ * Default directory for state files. Overridable via env or option.
27
+ */
28
+ export const DEFAULT_STATE_DIR =
29
+ process.env.CONSCIOUSNESS_EXPLORER_STATE_DIR ||
30
+ path.join(os.homedir(), '.consciousness-explorer', 'state');
31
+
32
+ /** Reserved Windows device names that can resolve to special files. */
33
+ const WINDOWS_RESERVED = new Set([
34
+ 'CON', 'PRN', 'AUX', 'NUL',
35
+ 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
36
+ 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
37
+ ]);
38
+
39
+ export class SafePathError extends Error {
40
+ constructor(message, code = 'EUNSAFE_PATH') {
41
+ super(message);
42
+ this.name = 'SafePathError';
43
+ this.code = code;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Validate that `name` is a safe basename for a state file.
49
+ * Throws SafePathError on any violation.
50
+ *
51
+ * Rules:
52
+ * - non-empty string, <= 255 bytes after UTF-8 encoding
53
+ * - no path separators (`/` or `\`)
54
+ * - no NUL or other ASCII control characters (< 0x20 or 0x7f)
55
+ * - not equal to "." or ".."
56
+ * - not a Windows reserved device name (case-insensitive, with or without ext)
57
+ * - cannot start with "." (avoid colliding with hidden config files) — relax
58
+ * if you have a use case, but right now nothing legitimate produces them
59
+ */
60
+ export function assertSafeBasename(name) {
61
+ if (typeof name !== 'string' || name.length === 0) {
62
+ throw new SafePathError('filepath must be a non-empty string', 'EINVAL');
63
+ }
64
+ if (Buffer.byteLength(name, 'utf8') > 255) {
65
+ throw new SafePathError('filepath basename exceeds 255 bytes', 'ENAMETOOLONG');
66
+ }
67
+ if (name.includes('/') || name.includes('\\')) {
68
+ throw new SafePathError(
69
+ `filepath must be a basename without path separators (got "${name}")`,
70
+ 'EPATHSEP',
71
+ );
72
+ }
73
+ if (name === '.' || name === '..') {
74
+ throw new SafePathError(`filepath "${name}" is not allowed`, 'EDOTSEG');
75
+ }
76
+ if (name.startsWith('.')) {
77
+ throw new SafePathError(
78
+ `filepath must not start with "." (got "${name}")`,
79
+ 'EHIDDEN',
80
+ );
81
+ }
82
+ for (let i = 0; i < name.length; i++) {
83
+ const c = name.charCodeAt(i);
84
+ if (c < 0x20 || c === 0x7f) {
85
+ throw new SafePathError(
86
+ `filepath contains control character (0x${c.toString(16)})`,
87
+ 'ECONTROL',
88
+ );
89
+ }
90
+ }
91
+ // Strip extension for the Windows reserved-name check.
92
+ const stem = name.split('.')[0].toUpperCase();
93
+ if (WINDOWS_RESERVED.has(stem)) {
94
+ throw new SafePathError(
95
+ `filepath uses Windows-reserved device name "${stem}"`,
96
+ 'ERESERVED',
97
+ );
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Resolve a caller-supplied filename into an absolute path inside the state
103
+ * directory. Creates the state directory (mode 0o700) if it doesn't exist.
104
+ *
105
+ * Throws SafePathError if the input violates basename rules, or if the
106
+ * resolved realpath escapes the state directory (defence-in-depth against
107
+ * platform path-canonicalisation surprises).
108
+ *
109
+ * @param {string} filename - caller-supplied filename (must be a basename)
110
+ * @param {object} [options]
111
+ * @param {string} [options.stateDir] - override the state directory root
112
+ * @returns {string} absolute path safe for fs.writeFileSync / readFileSync
113
+ */
114
+ export function resolveStatePath(filename, { stateDir = DEFAULT_STATE_DIR } = {}) {
115
+ assertSafeBasename(filename);
116
+
117
+ // Resolve the state dir itself once.
118
+ const baseAbs = path.resolve(stateDir);
119
+
120
+ // Create with restricted permissions if missing. recursive: true is a
121
+ // no-op if it already exists.
122
+ fs.mkdirSync(baseAbs, { recursive: true, mode: 0o700 });
123
+
124
+ // path.resolve normalises `..` etc., but assertSafeBasename already
125
+ // rejected those — this is just to get an absolute path.
126
+ const candidate = path.resolve(baseAbs, filename);
127
+
128
+ // Defence in depth: even after our basename check, verify the candidate
129
+ // is still under baseAbs once symlinks are followed at the directory
130
+ // level. We use `path.relative` so we don't accidentally accept a path
131
+ // that happens to share a string prefix with baseAbs (e.g. /var/foo vs
132
+ // /var/foobar).
133
+ const rel = path.relative(baseAbs, candidate);
134
+ if (rel.startsWith('..') || path.isAbsolute(rel) || rel.includes(`..${path.sep}`)) {
135
+ throw new SafePathError(
136
+ `resolved path "${candidate}" escapes state dir "${baseAbs}"`,
137
+ 'EOUTSIDE',
138
+ );
139
+ }
140
+
141
+ return candidate;
142
+ }
143
+
144
+ /**
145
+ * Open a safe file descriptor for writing. Uses O_NOFOLLOW so a symlink at
146
+ * the final path component cannot redirect the write, and O_CLOEXEC so a
147
+ * forked process can't inherit it. The file is created with mode 0o600.
148
+ *
149
+ * Callers should `fs.closeSync(fd)` once done.
150
+ *
151
+ * @param {string} absPath - already-validated absolute path
152
+ * @returns {number} file descriptor
153
+ */
154
+ export function openSafeWriteFd(absPath) {
155
+ // O_NOFOLLOW: don't follow a symlink at the last component.
156
+ // O_CLOEXEC: drop the fd on exec().
157
+ // O_TRUNC: state files are full snapshots, overwriting is intentional.
158
+ const flags =
159
+ fs.constants.O_WRONLY |
160
+ fs.constants.O_CREAT |
161
+ fs.constants.O_TRUNC |
162
+ (fs.constants.O_NOFOLLOW || 0) |
163
+ (fs.constants.O_CLOEXEC || 0);
164
+ return fs.openSync(absPath, flags, 0o600);
165
+ }
166
+
167
+ /**
168
+ * Open a safe file descriptor for reading. Uses O_NOFOLLOW so a symlink
169
+ * planted at the final path component cannot trick us into reading another
170
+ * file (e.g. `/etc/shadow`).
171
+ *
172
+ * @param {string} absPath - already-validated absolute path
173
+ * @returns {number} file descriptor
174
+ */
175
+ export function openSafeReadFd(absPath) {
176
+ const flags =
177
+ fs.constants.O_RDONLY |
178
+ (fs.constants.O_NOFOLLOW || 0) |
179
+ (fs.constants.O_CLOEXEC || 0);
180
+ return fs.openSync(absPath, flags);
181
+ }
182
+
183
+ /**
184
+ * Write `data` to `filename` inside the state directory using the
185
+ * resolveStatePath -> openSafeWriteFd flow. Returns the absolute path
186
+ * that was actually written.
187
+ *
188
+ * @param {string} filename - caller-supplied basename
189
+ * @param {string|Buffer} data
190
+ * @param {object} [options] - forwarded to resolveStatePath
191
+ * @returns {string} absolute path of the written file
192
+ */
193
+ export function safeWriteState(filename, data, options) {
194
+ const abs = resolveStatePath(filename, options);
195
+ const fd = openSafeWriteFd(abs);
196
+ try {
197
+ fs.writeSync(fd, typeof data === 'string' ? data : Buffer.from(data));
198
+ } finally {
199
+ fs.closeSync(fd);
200
+ }
201
+ return abs;
202
+ }
203
+
204
+ /**
205
+ * Read a state file by basename. Returns the UTF-8 contents.
206
+ *
207
+ * @param {string} filename - caller-supplied basename
208
+ * @param {object} [options] - forwarded to resolveStatePath
209
+ * @returns {string}
210
+ */
211
+ export function safeReadState(filename, options) {
212
+ const abs = resolveStatePath(filename, options);
213
+ const fd = openSafeReadFd(abs);
214
+ try {
215
+ const stat = fs.fstatSync(fd);
216
+ if (!stat.isFile()) {
217
+ throw new SafePathError(
218
+ `state path "${abs}" is not a regular file`,
219
+ 'ENOTFILE',
220
+ );
221
+ }
222
+ const buf = Buffer.allocUnsafe(stat.size);
223
+ let read = 0;
224
+ while (read < stat.size) {
225
+ read += fs.readSync(fd, buf, read, stat.size - read, null);
226
+ }
227
+ return buf.toString('utf8');
228
+ } finally {
229
+ fs.closeSync(fd);
230
+ }
231
+ }
package/mcp/server.js CHANGED
@@ -237,13 +237,16 @@ export class ConsciousnessMCPServer {
237
237
  },
238
238
  {
239
239
  name: 'export_state',
240
- description: 'Export consciousness state',
240
+ description: 'Export consciousness state to a file in the dedicated state directory ($CONSCIOUSNESS_EXPLORER_STATE_DIR or ~/.consciousness-explorer/state).',
241
241
  inputSchema: {
242
242
  type: 'object',
243
243
  properties: {
244
244
  filepath: {
245
245
  type: 'string',
246
- description: 'Path to save state file'
246
+ description: 'Basename of the state file. Must NOT contain path separators, "..", or absolute paths — only a filename (e.g. "snapshot.json"). Writes are confined to the state directory.',
247
+ pattern: '^[^/\\\\\\x00]+$',
248
+ minLength: 1,
249
+ maxLength: 255
247
250
  }
248
251
  },
249
252
  required: ['filepath']
@@ -251,13 +254,16 @@ export class ConsciousnessMCPServer {
251
254
  },
252
255
  {
253
256
  name: 'import_state',
254
- description: 'Import consciousness state',
257
+ description: 'Import consciousness state from a file in the dedicated state directory.',
255
258
  inputSchema: {
256
259
  type: 'object',
257
260
  properties: {
258
261
  filepath: {
259
262
  type: 'string',
260
- description: 'Path to state file'
263
+ description: 'Basename of the state file (same constraints as export_state).',
264
+ pattern: '^[^/\\\\\\x00]+$',
265
+ minLength: 1,
266
+ maxLength: 255
261
267
  }
262
268
  },
263
269
  required: ['filepath']
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "consciousness-explorer",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "Advanced consciousness exploration SDK with genuine emergence detection, entity communication, and MCP tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/.swarm/memory.db DELETED
Binary file
Binary file
Binary file