@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.
- package/cli/audit/ast-visitors.js +7 -1
- package/cli/audit/csharp-analyzer.js +360 -7
- package/cli/audit/csharp-analyzer.spec.js +216 -0
- package/cli/audit/extended-text-patterns.js +6 -3
- package/cli/audit/metrics.js +5 -3
- package/cli/audit/reporter-markdown.js +1 -1
- package/cli/audit/reporter-sections.js +1 -1
- package/cli/audit/rules-helpers.js +7 -1
- package/cli/audit/rules-registry.js +10 -0
- package/cli/audit/rules.js +2 -2
- package/cli/audit.js +16 -8
- package/cli/commands/cmd-audit.js +6 -4
- package/cli/config/loader.js +1 -0
- package/cli/config/profiles.js +3 -0
- package/cli/friction-fixes.spec.js +96 -0
- package/cli/installer-templates.js +37 -6
- package/cli/mcp/manifests.js +7 -2
- package/cli/mcp/tools-patch.js +4 -3
- package/cli/mcp/tools-read.js +3 -1
- package/cli/mcp/tools-team-locks.js +10 -3
- package/cli/mcp/tools-team-tasks.js +5 -2
- package/cli/mcp/tools-write.js +2 -1
- package/cli/mcp/tools.js +44 -1
- package/cli/patcher.js +3 -2
- package/cli/path-scope.js +113 -0
- package/cli/path-traversal.spec.js +242 -0
- package/cli/reader.js +3 -1
- package/cli/search.d.ts +2 -1
- package/cli/search.js +1 -21
- package/cli/search.spec.js +12 -0
- package/cli/team/team-commands-lock.js +17 -5
- package/cli/team/team-commands.js +2 -2
- package/cli/team/team-db-locks.js +50 -15
- package/package.json +1 -1
- package/scripts/pre-commit.sh +28 -6
|
@@ -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
|
|
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
|
|
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';
|
package/cli/search.spec.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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:
|
|
52
|
-
message: `${cleanId} acquired lock on ${
|
|
74
|
+
file_path: cleanPath,
|
|
75
|
+
message: `${cleanId} acquired lock on ${cleanPath}`
|
|
53
76
|
});
|
|
54
77
|
|
|
55
|
-
return { granted: true, lease: { file_path:
|
|
78
|
+
return { granted: true, lease: { file_path: cleanPath, locked_by: cleanId, expires_at: expiresAt, pid } };
|
|
56
79
|
}
|
|
57
80
|
|
|
58
|
-
return enqueueWaiter(db,
|
|
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(
|
|
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(
|
|
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:
|
|
85
|
-
message: `${cleanId} released lock on ${
|
|
113
|
+
file_path: cleanPath,
|
|
114
|
+
message: `${cleanId} released lock on ${cleanPath}`
|
|
86
115
|
});
|
|
87
116
|
|
|
88
|
-
const next = promoteNextWaiter(db,
|
|
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(
|
|
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(
|
|
138
|
+
const waiters = db.prepare(query).all(cleanPath);
|
|
104
139
|
return { lease: lease || null, waiters };
|
|
105
140
|
};
|