@chemx/starter-kit 26.9.26-253 → 26.9.28-1234

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.
@@ -0,0 +1,113 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Resolves a target path against a base directory and ensures it does not escape the boundary.
6
+ *
7
+ * @param {string} targetPath File or directory path to validate.
8
+ * @param {string} [baseDir=process.cwd()] Allowed root boundary directory.
9
+ * @returns {string} Fully resolved, validated path within baseDir.
10
+ * @throws {Error} If path contains null bytes, attempts traversal, or escapes baseDir.
11
+ */
12
+ export const resolveSafePath = (targetPath, baseDir = process.cwd()) => {
13
+ if (!targetPath || typeof targetPath !== 'string') {
14
+ throw new Error('A valid file path string is required.');
15
+ }
16
+
17
+ if (targetPath.includes('\0')) {
18
+ throw new Error('Null byte detected in path.');
19
+ }
20
+
21
+ const realBase = fs.existsSync(baseDir) ? fs.realpathSync(baseDir) : path.resolve(baseDir);
22
+ const resolvedTarget = path.isAbsolute(targetPath)
23
+ ? path.resolve(targetPath)
24
+ : path.resolve(realBase, targetPath);
25
+
26
+ const relLexical = path.relative(realBase, resolvedTarget);
27
+ const isLexicalEscape = relLexical.startsWith('..') || path.isAbsolute(relLexical);
28
+
29
+ if (isLexicalEscape) {
30
+ throw new Error(`Path traversal rejected: "${targetPath}" is outside allowed workspace root "${realBase}".`);
31
+ }
32
+
33
+ if (fs.existsSync(resolvedTarget)) {
34
+ const realTarget = fs.realpathSync(resolvedTarget);
35
+ const relReal = path.relative(realBase, realTarget);
36
+ const isSymlinkEscape = relReal.startsWith('..') || path.isAbsolute(relReal);
37
+ if (isSymlinkEscape) {
38
+ throw new Error(`Path traversal rejected: "${targetPath}" resolves outside allowed workspace root via symlink.`);
39
+ }
40
+ return realTarget;
41
+ }
42
+
43
+ return resolvedTarget;
44
+ };
45
+
46
+ /**
47
+ * Checks whether a target path attempts traversal or escapes the boundary.
48
+ *
49
+ * @param {string} targetPath Path to inspect.
50
+ * @param {string} [baseDir=process.cwd()] Boundary directory.
51
+ * @returns {boolean} True if the path escapes baseDir or is invalid, false otherwise.
52
+ */
53
+ export const isPathTraversal = (targetPath, baseDir = process.cwd()) => {
54
+ try {
55
+ const resolved = resolveSafePath(targetPath, baseDir);
56
+ const realBase = fs.existsSync(baseDir) ? fs.realpathSync(baseDir) : path.resolve(baseDir);
57
+ const rel = path.relative(realBase, resolved);
58
+ if (rel === '') return true;
59
+ return false;
60
+ } catch {
61
+ return true;
62
+ }
63
+ };
64
+
65
+ const hasDotNetProject = (cwd) => {
66
+ try {
67
+ const rootEntries = fs.readdirSync(cwd, { withFileTypes: true });
68
+ const hasSln = rootEntries.some((e) => e.isFile() && e.name.endsWith('.sln'));
69
+ const hasRootCsproj = rootEntries.some((e) => e.isFile() && e.name.endsWith('.csproj'));
70
+ if (hasSln || hasRootCsproj) return true;
71
+
72
+ return rootEntries.some((e) => {
73
+ const isCandidateDir = e.isDirectory() && e.name !== 'src' && e.name !== 'node_modules' && !e.name.startsWith('.');
74
+ if (!isCandidateDir) return false;
75
+ const subPath = path.join(cwd, e.name);
76
+ try {
77
+ return fs.readdirSync(subPath).some((f) => f.endsWith('.csproj'));
78
+ } catch (err) {
79
+ if (process.env.DEBUG) process.stderr.write(`[debug] Read failed: ${err?.message}\n`);
80
+ return false;
81
+ }
82
+ });
83
+ } catch (err) {
84
+ if (process.env.DEBUG) process.stderr.write(`[debug] Scan failed: ${err?.message}\n`);
85
+ return false;
86
+ }
87
+ };
88
+
89
+ export const resolveTargetDir = (customOrFlag = null, dirFlag = null, cwd = process.cwd()) => {
90
+ const isCustomPath = Boolean(customOrFlag && !customOrFlag.startsWith('--dir='));
91
+ if (isCustomPath) {
92
+ return customOrFlag;
93
+ }
94
+
95
+ const effectiveFlag = dirFlag || (customOrFlag?.startsWith('--dir=') ? customOrFlag : null);
96
+ if (effectiveFlag) {
97
+ const [, flagValue] = effectiveFlag.split('=');
98
+ if (flagValue !== undefined) {
99
+ return flagValue;
100
+ }
101
+ }
102
+
103
+ if (hasDotNetProject(cwd)) {
104
+ return '.';
105
+ }
106
+
107
+ const hasSrcDirectory = fs.existsSync(path.resolve(cwd, 'src'));
108
+ if (hasSrcDirectory) {
109
+ return 'src';
110
+ }
111
+
112
+ return '.';
113
+ };
@@ -0,0 +1,242 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { DatabaseSync } from 'node:sqlite';
7
+
8
+ import { resolveSafePath, isPathTraversal } from './path-scope.js';
9
+ import { readTokenOptimized } from './reader.js';
10
+ import { handleChemxRead } from './mcp/tools-read.js';
11
+ import { patchFile, writeFile } from './patcher.js';
12
+ import { handleChemxPatch } from './mcp/tools-patch.js';
13
+ import { handleChemxWrite } from './mcp/tools-write.js';
14
+ import { requestFileLock, releaseFileLock, getFileLockStatus } from './team/team-db-locks.js';
15
+ import { handleChemxTeamLock } from './mcp/tools-team-locks.js';
16
+ import { handleLockCommand, handleUnlockCommand } from './team/team-commands-lock.js';
17
+ import { initTeamSchema } from './team/team-schema.js';
18
+
19
+ test('path-scope: resolveSafePath allows legitimate workspace paths', () => {
20
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chemx-scope-test-'));
21
+ const testFile = path.join(tmpDir, 'src', 'index.js');
22
+ fs.mkdirSync(path.dirname(testFile), { recursive: true });
23
+ fs.writeFileSync(testFile, 'export const x = 1;', 'utf-8');
24
+
25
+ try {
26
+ const relResolved = resolveSafePath('src/index.js', tmpDir);
27
+ assert.strictEqual(relResolved, testFile);
28
+
29
+ const absResolved = resolveSafePath(testFile, tmpDir);
30
+ assert.strictEqual(absResolved, testFile);
31
+
32
+ assert.strictEqual(isPathTraversal('src/index.js', tmpDir), false);
33
+ assert.strictEqual(isPathTraversal(testFile, tmpDir), false);
34
+ } finally {
35
+ fs.rmSync(tmpDir, { recursive: true, force: true });
36
+ }
37
+ });
38
+
39
+ test('path-scope: resolveSafePath and isPathTraversal reject traversal attacks', () => {
40
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chemx-scope-attack-'));
41
+
42
+ try {
43
+ assert.throws(
44
+ () => resolveSafePath('../../../etc/passwd', tmpDir),
45
+ /Path traversal rejected/
46
+ );
47
+ assert.throws(
48
+ () => resolveSafePath('/etc/passwd', tmpDir),
49
+ /Path traversal rejected/
50
+ );
51
+ assert.throws(
52
+ () => resolveSafePath('foo\0bar.js', tmpDir),
53
+ /Null byte detected/
54
+ );
55
+
56
+ assert.strictEqual(isPathTraversal('../../../etc/passwd', tmpDir), true);
57
+ assert.strictEqual(isPathTraversal('/etc/passwd', tmpDir), true);
58
+ assert.strictEqual(isPathTraversal('.', tmpDir), true);
59
+ } finally {
60
+ fs.rmSync(tmpDir, { recursive: true, force: true });
61
+ }
62
+ });
63
+
64
+ test('path-scope: rejects symlink escapes targeting outside workspace', () => {
65
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chemx-symlink-root-'));
66
+ const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chemx-symlink-outside-'));
67
+ const outsideFile = path.join(outsideDir, 'secret.txt');
68
+ fs.writeFileSync(outsideFile, 'secret-data', 'utf-8');
69
+
70
+ const symlinkPath = path.join(tmpDir, 'symlink-secret.txt');
71
+ try {
72
+ fs.symlinkSync(outsideFile, symlinkPath);
73
+ assert.throws(
74
+ () => resolveSafePath('symlink-secret.txt', tmpDir),
75
+ /Path traversal rejected/
76
+ );
77
+ assert.strictEqual(isPathTraversal('symlink-secret.txt', tmpDir), true);
78
+ } finally {
79
+ fs.rmSync(tmpDir, { recursive: true, force: true });
80
+ fs.rmSync(outsideDir, { recursive: true, force: true });
81
+ }
82
+ });
83
+
84
+ test('reader: readTokenOptimized and handleChemxRead block path traversal', () => {
85
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chemx-reader-sec-'));
86
+ const legitimateFile = path.join(tmpDir, 'legit.ts');
87
+ fs.writeFileSync(legitimateFile, 'export const safe = true;\n', 'utf-8');
88
+
89
+ try {
90
+ const safeRes = readTokenOptimized('legit.ts', { cwd: tmpDir });
91
+ assert.ok(safeRes.content.includes('export const safe = true;'));
92
+
93
+ assert.throws(
94
+ () => readTokenOptimized('../../../etc/passwd', { cwd: tmpDir }),
95
+ /Path traversal rejected/
96
+ );
97
+ assert.throws(
98
+ () => readTokenOptimized('/etc/passwd', { cwd: tmpDir }),
99
+ /Path traversal rejected/
100
+ );
101
+
102
+ assert.throws(
103
+ () => handleChemxRead({ path: '../../../etc/passwd', cwd: tmpDir }, tmpDir),
104
+ /Path traversal rejected/
105
+ );
106
+ assert.throws(
107
+ () => handleChemxRead({ path: '/etc/passwd', cwd: tmpDir }, tmpDir),
108
+ /Path traversal rejected/
109
+ );
110
+ } finally {
111
+ fs.rmSync(tmpDir, { recursive: true, force: true });
112
+ }
113
+ });
114
+
115
+ test('patcher: patchFile, writeFile, and MCP tools block path traversal', () => {
116
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chemx-patcher-sec-'));
117
+ const legitimateFile = path.join(tmpDir, 'legit.ts');
118
+ fs.writeFileSync(legitimateFile, 'export const val = 1;\n', 'utf-8');
119
+
120
+ try {
121
+ const patchRes = patchFile('legit.ts', {
122
+ targetContent: 'val = 1;',
123
+ replacementContent: 'val = 2;',
124
+ cwd: tmpDir,
125
+ skipIndex: true
126
+ });
127
+ assert.strictEqual(patchRes.status, 'ok');
128
+
129
+ assert.throws(
130
+ () => patchFile('../../../etc/passwd', {
131
+ targetContent: 'root',
132
+ replacementContent: 'hacked',
133
+ cwd: tmpDir,
134
+ skipIndex: true
135
+ }),
136
+ /Path traversal rejected/
137
+ );
138
+ assert.throws(
139
+ () => patchFile('/etc/passwd', {
140
+ targetContent: 'root',
141
+ replacementContent: 'hacked',
142
+ cwd: tmpDir,
143
+ skipIndex: true
144
+ }),
145
+ /Path traversal rejected/
146
+ );
147
+
148
+ assert.throws(
149
+ () => writeFile('../../../etc/evil.txt', {
150
+ content: 'exploit',
151
+ cwd: tmpDir,
152
+ skipIndex: true
153
+ }),
154
+ /Path traversal rejected/
155
+ );
156
+ assert.throws(
157
+ () => writeFile('/tmp/evil-outside.txt', {
158
+ content: 'exploit',
159
+ cwd: tmpDir,
160
+ skipIndex: true
161
+ }),
162
+ /Path traversal rejected/
163
+ );
164
+
165
+ assert.throws(
166
+ () => handleChemxPatch({
167
+ path: '../../../etc/passwd',
168
+ targetContent: 'a',
169
+ replacementContent: 'b'
170
+ }, tmpDir),
171
+ /Path traversal rejected/
172
+ );
173
+
174
+ assert.throws(
175
+ () => handleChemxWrite({
176
+ path: '../../../etc/passwd',
177
+ content: 'malicious'
178
+ }, tmpDir),
179
+ /Path traversal rejected/
180
+ );
181
+ } finally {
182
+ fs.rmSync(tmpDir, { recursive: true, force: true });
183
+ }
184
+ });
185
+
186
+ test('team lock: requestFileLock, releaseFileLock, and getFileLockStatus block path traversal', () => {
187
+ const db = new DatabaseSync(':memory:');
188
+ db.exec('PRAGMA foreign_keys = ON;');
189
+ initTeamSchema(db);
190
+
191
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chemx-team-sec-'));
192
+
193
+ try {
194
+ const safeLock = requestFileLock(db, 'src/button.vue', '@agent-1', { cwd: tmpDir });
195
+ assert.strictEqual(safeLock.granted, true);
196
+ assert.strictEqual(safeLock.lease.file_path, 'src/button.vue');
197
+
198
+ const safeStatus = getFileLockStatus(db, 'src/button.vue', { cwd: tmpDir });
199
+ assert.strictEqual(safeStatus.lease.locked_by, '@agent-1');
200
+
201
+ const safeUnlock = releaseFileLock(db, 'src/button.vue', '@agent-1', { cwd: tmpDir });
202
+ assert.strictEqual(safeUnlock.success, true);
203
+
204
+ const badLock1 = requestFileLock(db, '../../../etc/passwd', '@attacker', { cwd: tmpDir });
205
+ assert.strictEqual(badLock1.granted, false);
206
+ assert.strictEqual(badLock1.reason, 'path_traversal');
207
+
208
+ const badLock2 = requestFileLock(db, '/etc/passwd', '@attacker', { cwd: tmpDir });
209
+ assert.strictEqual(badLock2.granted, false);
210
+ assert.strictEqual(badLock2.reason, 'path_traversal');
211
+
212
+ const badUnlock = releaseFileLock(db, '../../../etc/passwd', '@attacker', { cwd: tmpDir });
213
+ assert.strictEqual(badUnlock.success, false);
214
+ assert.strictEqual(badUnlock.reason, 'path_traversal');
215
+
216
+ const badStatus = getFileLockStatus(db, '../../../etc/passwd', { cwd: tmpDir });
217
+ assert.strictEqual(badStatus.error, 'path_traversal');
218
+
219
+ const cliLockRes = handleLockCommand(db, ['acquire', '../../../etc/passwd'], {}, false, tmpDir);
220
+ assert.strictEqual(cliLockRes.error, 'path_traversal');
221
+
222
+ const cliUnlockRes = handleUnlockCommand(db, ['../../../etc/passwd'], {}, false, tmpDir);
223
+ assert.strictEqual(cliUnlockRes.error, 'path_traversal');
224
+ } finally {
225
+ fs.rmSync(tmpDir, { recursive: true, force: true });
226
+ }
227
+ });
228
+
229
+ test('team lock: handleChemxTeamLock MCP handler rejects path traversal', async () => {
230
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chemx-mcp-lock-sec-'));
231
+ try {
232
+ const res = await handleChemxTeamLock({
233
+ action: 'acquire',
234
+ filePath: '../../../etc/passwd',
235
+ agentId: '@attacker'
236
+ }, tmpDir);
237
+
238
+ assert.strictEqual(res.error, 'path_traversal');
239
+ } finally {
240
+ fs.rmSync(tmpDir, { recursive: true, force: true });
241
+ }
242
+ });
package/cli/reader.js CHANGED
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { parse } from '@babel/parser';
4
4
  import traverseModule from '@babel/traverse';
5
5
  import { ANSI } from './theme.js';
6
+ import { resolveSafePath } from './path-scope.js';
6
7
 
7
8
  import {
8
9
  stripCodeComments,
@@ -214,7 +215,8 @@ export const readTokenOptimized = (targetPath, options = {}) => {
214
215
  if (endLine === undefined && colonMatch[3]) endLine = parseInt(colonMatch[3], 10);
215
216
  }
216
217
 
217
- const resolvedPath = path.resolve(process.cwd(), rawPath);
218
+ const cwd = options.cwd || process.cwd();
219
+ const resolvedPath = resolveSafePath(rawPath, cwd);
218
220
 
219
221
  if (!fs.existsSync(resolvedPath)) {
220
222
  throw new Error(`File not found: ${rawPath}`);
package/cli/search.d.ts CHANGED
@@ -101,7 +101,8 @@ export declare function runSearch(
101
101
 
102
102
  export declare function resolveTargetDir(
103
103
  customOrFlag?: string | null,
104
- dirFlag?: string | null
104
+ dirFlag?: string | null,
105
+ cwd?: string
105
106
  ): string;
106
107
 
107
108
  export declare function findSymbolDefinition(
package/cli/search.js CHANGED
@@ -207,27 +207,7 @@ const formatTierBadge = (tier) => {
207
207
  return map[tier] || `${ANSI.DIM}[${tier}]${ANSI.RESET}`;
208
208
  };
209
209
 
210
- export const resolveTargetDir = (customOrFlag = null, dirFlag = null) => {
211
- const isCustomPath = Boolean(customOrFlag && !customOrFlag.startsWith('--dir='));
212
- if (isCustomPath) {
213
- return customOrFlag;
214
- }
215
-
216
- const effectiveFlag = dirFlag || (customOrFlag?.startsWith('--dir=') ? customOrFlag : null);
217
- if (effectiveFlag) {
218
- const [, flagValue] = effectiveFlag.split('=');
219
- if (flagValue !== undefined) {
220
- return flagValue;
221
- }
222
- }
223
-
224
- const hasSrcDirectory = fs.existsSync('src');
225
- if (hasSrcDirectory) {
226
- return 'src';
227
- }
228
-
229
- return '.';
230
- };
210
+ export { resolveTargetDir } from './path-scope.js';
231
211
 
232
212
  export const printSearchHelp = () => {
233
213
  const BOLD = '\x1b[1m';
@@ -57,6 +57,18 @@ test('resolveTargetDir: defaults to src if present, otherwise . when no flags pr
57
57
  assert.strictEqual(resultNoArgs, expectedDefault);
58
58
  });
59
59
 
60
+ test('resolveTargetDir: defaults to . when .sln or csproj exists at repo root', () => {
61
+ const tmpDir = path.resolve('scratch/test-dotnet-repo');
62
+ fs.mkdirSync(tmpDir, { recursive: true });
63
+ fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
64
+ fs.writeFileSync(path.join(tmpDir, 'Solution.sln'), '', 'utf8');
65
+
66
+ const resolved = resolveTargetDir(null, null, tmpDir);
67
+ assert.strictEqual(resolved, '.');
68
+
69
+ fs.rmSync(tmpDir, { recursive: true, force: true });
70
+ });
71
+
60
72
  test('search-db: indexes symbols with line ranges and finds definition', () => {
61
73
  const db = openIndexDb();
62
74
  if (!db) return;
@@ -1,7 +1,8 @@
1
1
  import { requestFileLock, releaseFileLock } from './team-db.js';
2
2
  import { formatLockHelpCard } from './team-format.js';
3
+ import { isPathTraversal } from '../path-scope.js';
3
4
 
4
- export const handleLockCommand = (db, nonFlagPositional, flags, isCli) => {
5
+ export const handleLockCommand = (db, nonFlagPositional, flags, isCli, cwd = process.cwd()) => {
5
6
  const isLockHelp = flags.help || nonFlagPositional.includes('--help') || nonFlagPositional.includes('-h') || nonFlagPositional.includes('help');
6
7
  if (isLockHelp) {
7
8
  if (isCli) process.stdout.write(formatLockHelpCard());
@@ -24,13 +25,19 @@ export const handleLockCommand = (db, nonFlagPositional, flags, isCli) => {
24
25
  }
25
26
 
26
27
  if (action === 'release') {
27
- return handleUnlockCommand(db, [file], flags, isCli);
28
+ return handleUnlockCommand(db, [file], flags, isCli, cwd);
29
+ }
30
+
31
+ if (isPathTraversal(file, cwd)) {
32
+ if (isCli) process.stderr.write('\x1b[31m✕ Path traversal rejected: file path must be within workspace\x1b[0m\n');
33
+ return { error: 'path_traversal' };
28
34
  }
29
35
 
30
36
  const res = requestFileLock(db, file, flags.as || '@agent', {
31
37
  purpose: flags.purpose,
32
38
  priority: flags.priority,
33
- pid: flags.pid ? Number(flags.pid) : 0
39
+ pid: flags.pid ? Number(flags.pid) : 0,
40
+ cwd
34
41
  });
35
42
 
36
43
  if (isCli) {
@@ -41,7 +48,7 @@ export const handleLockCommand = (db, nonFlagPositional, flags, isCli) => {
41
48
  return res;
42
49
  };
43
50
 
44
- export const handleUnlockCommand = (db, nonFlagPositional, flags, isCli) => {
51
+ export const handleUnlockCommand = (db, nonFlagPositional, flags, isCli, cwd = process.cwd()) => {
45
52
  const isUnlockHelp = flags.help || nonFlagPositional.includes('--help') || nonFlagPositional.includes('-h') || nonFlagPositional.includes('help');
46
53
  if (isUnlockHelp) {
47
54
  if (isCli) process.stdout.write(formatLockHelpCard());
@@ -54,7 +61,12 @@ export const handleUnlockCommand = (db, nonFlagPositional, flags, isCli) => {
54
61
  return { error: 'filePath required' };
55
62
  }
56
63
 
57
- const res = releaseFileLock(db, file, flags.as || '@agent');
64
+ if (isPathTraversal(file, cwd)) {
65
+ if (isCli) process.stderr.write('\x1b[31m✕ Path traversal rejected: file path must be within workspace\x1b[0m\n');
66
+ return { error: 'path_traversal' };
67
+ }
68
+
69
+ const res = releaseFileLock(db, file, flags.as || '@agent', { cwd });
58
70
  if (isCli) {
59
71
  if (flags.isJson) process.stdout.write(`${JSON.stringify(res, null, 2)}\n`);
60
72
  else if (res.success) process.stdout.write(`\x1b[32m✔\x1b[0m Released lock on ${file}\n`);
@@ -396,11 +396,11 @@ export const runTeamCli = (rawArgs = [], isCli = false, cwd = process.cwd()) =>
396
396
  }
397
397
 
398
398
  if (subCommand === 'lock') {
399
- return handleLockCommand(db, nonFlagPositional, flags, isCli);
399
+ return handleLockCommand(db, nonFlagPositional, flags, isCli, cwd);
400
400
  }
401
401
 
402
402
  if (subCommand === 'unlock') {
403
- return handleUnlockCommand(db, nonFlagPositional, flags, isCli);
403
+ return handleUnlockCommand(db, nonFlagPositional, flags, isCli, cwd);
404
404
  }
405
405
 
406
406
  if (subCommand === 'triage') {
@@ -3,12 +3,29 @@
3
3
  * Prevents race collisions with deterministic queueing and reactive promotion
4
4
  */
5
5
 
6
+ import path from 'node:path';
7
+ import { isPathTraversal, resolveSafePath } from '../path-scope.js';
6
8
  import { postFeedEvent } from './team-db-feed.js';
7
9
  import { DEFAULT_TTL_MS, cleanExpiredLeases, promoteNextWaiter, enqueueWaiter } from './team-db-lock-promotion.js';
8
10
  import { withImmediateTransaction } from './team-db-transaction.js';
9
11
 
10
12
  export { cleanExpiredLeases, promoteNextWaiter } from './team-db-lock-promotion.js';
11
13
 
14
+ const resolveBaseDir = (db, options = {}) => {
15
+ if (options.cwd) return options.cwd;
16
+ try {
17
+ if (typeof db?.location === 'function') {
18
+ const loc = db.location();
19
+ if (loc && loc !== ':memory:' && loc.includes('.chemx')) {
20
+ return path.dirname(path.dirname(path.resolve(loc)));
21
+ }
22
+ }
23
+ } catch {
24
+ // fallback
25
+ }
26
+ return process.cwd();
27
+ };
28
+
12
29
  const normalizeAgentId = (id) => {
13
30
  const hasId = Boolean(id);
14
31
  if (!hasId) return null;
@@ -23,13 +40,19 @@ export const requestFileLock = (db, filePath, agentId, options = {}) => {
23
40
  const canRequest = hasDb && hasPath && hasAgent;
24
41
  if (!canRequest) return { granted: false, reason: 'missing_args' };
25
42
 
43
+ const baseDir = resolveBaseDir(db, options);
44
+ if (isPathTraversal(filePath, baseDir)) {
45
+ return { granted: false, reason: 'path_traversal' };
46
+ }
47
+ const cleanPath = path.relative(baseDir, resolveSafePath(filePath, baseDir));
48
+
26
49
  const cleanId = normalizeAgentId(agentId);
27
50
  const pid = typeof options.pid === 'number' ? options.pid : 0;
28
51
 
29
52
  return withImmediateTransaction(db, () => {
30
53
  cleanExpiredLeases(db);
31
54
 
32
- const existingLease = db.prepare('SELECT * FROM file_leases WHERE file_path = ?').get(filePath);
55
+ const existingLease = db.prepare('SELECT * FROM file_leases WHERE file_path = ?').get(cleanPath);
33
56
  const now = Date.now();
34
57
  const ttlMs = options.ttlMs || DEFAULT_TTL_MS;
35
58
  const expiresAt = now + ttlMs;
@@ -43,63 +66,75 @@ export const requestFileLock = (db, filePath, agentId, options = {}) => {
43
66
  expires_at = excluded.expires_at,
44
67
  purpose = excluded.purpose,
45
68
  pid = excluded.pid`;
46
- db.prepare(upsertSql).run(filePath, cleanId, now, expiresAt, purpose, pid);
69
+ db.prepare(upsertSql).run(cleanPath, cleanId, now, expiresAt, purpose, pid);
47
70
 
48
71
  postFeedEvent(db, {
49
72
  author_id: cleanId,
50
73
  event_type: 'lock_acquired',
51
- file_path: filePath,
52
- message: `${cleanId} acquired lock on ${filePath}`
74
+ file_path: cleanPath,
75
+ message: `${cleanId} acquired lock on ${cleanPath}`
53
76
  });
54
77
 
55
- return { granted: true, lease: { file_path: filePath, locked_by: cleanId, expires_at: expiresAt, pid } };
78
+ return { granted: true, lease: { file_path: cleanPath, locked_by: cleanId, expires_at: expiresAt, pid } };
56
79
  }
57
80
 
58
- return enqueueWaiter(db, filePath, cleanId, existingLease, options, now);
81
+ return enqueueWaiter(db, cleanPath, cleanId, existingLease, options, now);
59
82
  });
60
83
  };
61
84
 
62
- export const releaseFileLock = (db, filePath, agentId) => {
85
+ export const releaseFileLock = (db, filePath, agentId, options = {}) => {
63
86
  const hasDb = Boolean(db);
64
87
  const hasPath = Boolean(filePath);
65
88
  const hasAgent = Boolean(agentId);
66
89
  const canRelease = hasDb && hasPath && hasAgent;
67
90
  if (!canRelease) return { success: false, reason: 'missing_args' };
68
91
 
92
+ const baseDir = resolveBaseDir(db, options);
93
+ if (isPathTraversal(filePath, baseDir)) {
94
+ return { success: false, reason: 'path_traversal' };
95
+ }
96
+ const cleanPath = path.relative(baseDir, resolveSafePath(filePath, baseDir));
97
+
69
98
  const cleanId = normalizeAgentId(agentId);
70
99
 
71
100
  return withImmediateTransaction(db, () => {
72
101
  cleanExpiredLeases(db);
73
102
 
74
- const existingLease = db.prepare('SELECT * FROM file_leases WHERE file_path = ?').get(filePath);
103
+ const existingLease = db.prepare('SELECT * FROM file_leases WHERE file_path = ?').get(cleanPath);
75
104
  const isHolder = Boolean(existingLease) && existingLease.locked_by === cleanId;
76
105
  if (!isHolder) {
77
106
  return { success: false, reason: 'not_holder' };
78
107
  }
79
108
 
80
- db.prepare('DELETE FROM file_leases WHERE file_path = ?').run(filePath);
109
+ db.prepare('DELETE FROM file_leases WHERE file_path = ?').run(cleanPath);
81
110
  postFeedEvent(db, {
82
111
  author_id: cleanId,
83
112
  event_type: 'lock_released',
84
- file_path: filePath,
85
- message: `${cleanId} released lock on ${filePath}`
113
+ file_path: cleanPath,
114
+ message: `${cleanId} released lock on ${cleanPath}`
86
115
  });
87
116
 
88
- const next = promoteNextWaiter(db, filePath);
117
+ const next = promoteNextWaiter(db, cleanPath);
89
118
  const promotedWaiter = next ? next.waiter.agent_id : null;
90
119
  return { success: true, promotedWaiter };
91
120
  });
92
121
  };
93
122
 
94
- export const getFileLockStatus = (db, filePath) => {
123
+ export const getFileLockStatus = (db, filePath, options = {}) => {
95
124
  const hasDb = Boolean(db);
96
125
  const hasPath = Boolean(filePath);
97
126
  const canGet = hasDb && hasPath;
98
127
  if (!canGet) return null;
99
128
 
129
+ const baseDir = resolveBaseDir(db, options);
130
+ if (isPathTraversal(filePath, baseDir)) {
131
+ return { lease: null, waiters: [], error: 'path_traversal' };
132
+ }
133
+ const cleanPath = path.relative(baseDir, resolveSafePath(filePath, baseDir));
134
+
100
135
  cleanExpiredLeases(db);
101
- const lease = db.prepare('SELECT * FROM file_leases WHERE file_path = ?').get(filePath);
136
+ const lease = db.prepare('SELECT * FROM file_leases WHERE file_path = ?').get(cleanPath);
102
137
  const query = "SELECT * FROM file_lock_queue WHERE file_path = ? AND status = 'waiting' ORDER BY priority ASC, id ASC";
103
- const waiters = db.prepare(query).all(filePath);
138
+ const waiters = db.prepare(query).all(cleanPath);
104
139
  return { lease: lease || null, waiters };
105
140
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chemx/starter-kit",
3
- "version": "26.9.26-253",
3
+ "version": "26.9.28-1234",
4
4
  "description": "Chemical X Protocol: Private drop-in architecture starter kit and capsule generator",
5
5
  "type": "module",
6
6
  "bin": {