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 +15 -4
- package/lib/safe-path.js +231 -0
- package/mcp/server.js +10 -4
- package/package.json +1 -1
- package/.swarm/memory.db +0 -0
- package/.swarm/memory.db-shm +0 -0
- package/.swarm/memory.db-wal +0 -0
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
|
|
288
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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();
|
package/lib/safe-path.js
ADDED
|
@@ -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: '
|
|
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: '
|
|
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
package/.swarm/memory.db
DELETED
|
Binary file
|
package/.swarm/memory.db-shm
DELETED
|
Binary file
|
package/.swarm/memory.db-wal
DELETED
|
Binary file
|