@chemx/create-chemx 26.9.26-253 → 26.9.28-276
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/extended-text-patterns.js +6 -3
- package/cli/audit/rules-helpers.js +7 -1
- package/cli/audit/rules.js +1 -1
- package/cli/config/loader.js +1 -0
- package/cli/config/profiles.js +3 -0
- package/cli/friction-fixes.spec.js +96 -0
- 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 +63 -0
- package/cli/path-traversal.spec.js +242 -0
- package/cli/reader.js +3 -1
- 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
|
@@ -23,12 +23,18 @@ export const createAstVisitors = ({ relativePath, violations, hookRegistry, conf
|
|
|
23
23
|
const isCustomHook = isCustomHookFunction(astPath);
|
|
24
24
|
|
|
25
25
|
// Pillar 3: Hook Saturation
|
|
26
|
+
const CONTROLLER_WRAPPER_EXCLUSIONS = new Set([
|
|
27
|
+
'useSharedStatusController',
|
|
28
|
+
'useSharedController',
|
|
29
|
+
'useController'
|
|
30
|
+
]);
|
|
26
31
|
let hookCount = 0;
|
|
27
32
|
astPath.traverse({
|
|
28
33
|
CallExpression(callPath) {
|
|
29
34
|
const callee = callPath.node.callee;
|
|
30
35
|
if (t.isIdentifier(callee) && /^use[A-Z0-9]/.test(callee.name)) {
|
|
31
|
-
|
|
36
|
+
const isExcluded = CONTROLLER_WRAPPER_EXCLUSIONS.has(callee.name);
|
|
37
|
+
if (!isExcluded && callPath.getFunctionParent() === astPath) {
|
|
32
38
|
hookCount += 1;
|
|
33
39
|
}
|
|
34
40
|
}
|
|
@@ -32,7 +32,10 @@ const JAVASCRIPT_URL_TEMPLATE_PATTERN = /\b(?:href|src|action|formaction)\s*=\s*
|
|
|
32
32
|
/**
|
|
33
33
|
* Pillar 10: Check for co-located test files for molecule capsules.
|
|
34
34
|
*/
|
|
35
|
-
export const checkMoleculeCoLocatedTest = (filePath, relativePath, violations) => {
|
|
35
|
+
export const checkMoleculeCoLocatedTest = (filePath, relativePath, violations, config = {}) => {
|
|
36
|
+
const enforceColocated = config.enforceColocatedTests ?? (config.profile === 'atomic-strict');
|
|
37
|
+
if (!enforceColocated) return;
|
|
38
|
+
|
|
36
39
|
const baseName = path.basename(filePath);
|
|
37
40
|
const ext = path.extname(filePath);
|
|
38
41
|
|
|
@@ -194,8 +197,8 @@ export const checkControllerViewContract = (filePath, relativePath, content, vio
|
|
|
194
197
|
}
|
|
195
198
|
};
|
|
196
199
|
|
|
197
|
-
export const checkExtendedTextPatterns = (content, lines, relativePath, filePath, violations) => {
|
|
198
|
-
checkMoleculeCoLocatedTest(filePath, relativePath, violations);
|
|
200
|
+
export const checkExtendedTextPatterns = (content, lines, relativePath, filePath, violations, config = {}) => {
|
|
201
|
+
checkMoleculeCoLocatedTest(filePath, relativePath, violations, config);
|
|
199
202
|
checkControllerViewContract(filePath, relativePath, content, violations);
|
|
200
203
|
|
|
201
204
|
const isTestFile = /\.(test|spec)\.[jt]sx?$/.test(filePath)
|
|
@@ -17,7 +17,13 @@ export const countLogicalOperators = (node) => {
|
|
|
17
17
|
export const extractParseableCode = (content, ext) => {
|
|
18
18
|
if (ext === '.vue') {
|
|
19
19
|
const scriptMatch = content.match(/<script\b[^>]*>([\s\S]*?)<\/script>/i);
|
|
20
|
-
|
|
20
|
+
if (!scriptMatch) return '';
|
|
21
|
+
const scriptStartIndex = scriptMatch.index || 0;
|
|
22
|
+
const preScript = content.slice(0, scriptStartIndex);
|
|
23
|
+
const openTag = scriptMatch[0].match(/<script\b[^>]*>/i)?.[0] || '';
|
|
24
|
+
const preContent = preScript + openTag;
|
|
25
|
+
const leadingNewlines = preContent.split('\n').length - 1;
|
|
26
|
+
return '\n'.repeat(leadingNewlines) + scriptMatch[1];
|
|
21
27
|
}
|
|
22
28
|
return content;
|
|
23
29
|
};
|
package/cli/audit/rules.js
CHANGED
|
@@ -107,7 +107,7 @@ export const auditCode = (content, filePath, relativePath, options = {}) => {
|
|
|
107
107
|
checkSlopTextPatterns(content, lines, relativePath, violations);
|
|
108
108
|
|
|
109
109
|
// Pillars 8-11: Extended Fast Checks (A11y, Security secrets, Fake tests, Co-located specs)
|
|
110
|
-
checkExtendedTextPatterns(content, lines, relativePath, filePath, violations);
|
|
110
|
+
checkExtendedTextPatterns(content, lines, relativePath, filePath, violations, config);
|
|
111
111
|
|
|
112
112
|
const lang = getLanguageForFile(filePath);
|
|
113
113
|
if (lang?.id === 'csharp') {
|
package/cli/config/loader.js
CHANGED
|
@@ -25,6 +25,7 @@ const normalizeRuleKeys = (rules = {}) => {
|
|
|
25
25
|
'max-prop-count': 'maxPropCount',
|
|
26
26
|
'prefer-design-tokens': 'preferDesignTokens',
|
|
27
27
|
'enforce-file-length': 'enforceFileLength',
|
|
28
|
+
'enforce-colocated-tests': 'enforceColocatedTests',
|
|
28
29
|
'rule-of-three-abstractions': 'ruleOfThreeAbstractions',
|
|
29
30
|
'ai-slop-detection': 'aiSlopDetection',
|
|
30
31
|
'max-line-count-warning': 'maxLineCountWarning'
|
package/cli/config/profiles.js
CHANGED
|
@@ -14,6 +14,7 @@ export const PROFILES = {
|
|
|
14
14
|
maxPropCount: 7,
|
|
15
15
|
preferDesignTokens: 'warning',
|
|
16
16
|
enforceFileLength: false,
|
|
17
|
+
enforceColocatedTests: false,
|
|
17
18
|
ruleOfThreeAbstractions: true,
|
|
18
19
|
aiSlopDetection: 'critical',
|
|
19
20
|
maxLineCountWarning: 250
|
|
@@ -28,6 +29,7 @@ export const PROFILES = {
|
|
|
28
29
|
maxPropCount: 5,
|
|
29
30
|
preferDesignTokens: 'error',
|
|
30
31
|
enforceFileLength: true,
|
|
32
|
+
enforceColocatedTests: true,
|
|
31
33
|
ruleOfThreeAbstractions: false,
|
|
32
34
|
aiSlopDetection: 'critical',
|
|
33
35
|
maxLineCountWarning: 100
|
|
@@ -42,6 +44,7 @@ export const PROFILES = {
|
|
|
42
44
|
maxPropCount: 12,
|
|
43
45
|
preferDesignTokens: 'off',
|
|
44
46
|
enforceFileLength: false,
|
|
47
|
+
enforceColocatedTests: false,
|
|
45
48
|
ruleOfThreeAbstractions: true,
|
|
46
49
|
aiSlopDetection: 'medium',
|
|
47
50
|
maxLineCountWarning: 500
|
|
@@ -0,0 +1,96 @@
|
|
|
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 { handleChemx } from './mcp/tools.js';
|
|
6
|
+
import { auditCode } from './audit/rules.js';
|
|
7
|
+
|
|
8
|
+
test('Friction 1: team_task add and team task add persist task rows', async () => {
|
|
9
|
+
const resCmd = await handleChemx({ command: 'team task add CLI Task Test' });
|
|
10
|
+
assert.ok(resCmd && resCmd.id, 'Expected task object with id from team task add');
|
|
11
|
+
assert.strictEqual(resCmd.title, 'CLI Task Test');
|
|
12
|
+
|
|
13
|
+
const resCmd2 = await handleChemx({ command: 'team_task add Direct SubCmd Task' });
|
|
14
|
+
assert.ok(resCmd2 && resCmd2.id, 'Expected task object with id from team_task add');
|
|
15
|
+
assert.strictEqual(resCmd2.title, 'Direct SubCmd Task');
|
|
16
|
+
|
|
17
|
+
const resInfer = await handleChemx({ action: 'team_task', params: { title: 'Inferred Add Task' } });
|
|
18
|
+
assert.ok(resInfer && resInfer.id, 'Expected task object with id when title is given');
|
|
19
|
+
assert.strictEqual(resInfer.title, 'Inferred Add Task');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('Friction 2: patch handles all parameter aliases and command strings', async () => {
|
|
23
|
+
const testFile = path.resolve('test-patch-spec.txt');
|
|
24
|
+
fs.writeFileSync(testFile, 'line 1\nOLD_STRING\nline 3\n');
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
await handleChemx({ action: 'patch', params: { path: 'test-patch-spec.txt', target: 'OLD_STRING', replace: 'NEW_STRING_1' } });
|
|
28
|
+
assert.strictEqual(fs.readFileSync(testFile, 'utf8'), 'line 1\nNEW_STRING_1\nline 3\n');
|
|
29
|
+
|
|
30
|
+
await handleChemx({ action: 'patch', params: { path: 'test-patch-spec.txt', search: 'NEW_STRING_1', replacement: 'NEW_STRING_2' } });
|
|
31
|
+
assert.strictEqual(fs.readFileSync(testFile, 'utf8'), 'line 1\nNEW_STRING_2\nline 3\n');
|
|
32
|
+
|
|
33
|
+
await handleChemx({ action: 'patch', params: { path: 'test-patch-spec.txt', targetContent: 'NEW_STRING_2', replacementContent: 'FINAL_STRING' } });
|
|
34
|
+
assert.strictEqual(fs.readFileSync(testFile, 'utf8'), 'line 1\nFINAL_STRING\nline 3\n');
|
|
35
|
+
} finally {
|
|
36
|
+
if (fs.existsSync(testFile)) fs.unlinkSync(testFile);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('Friction 3: Vue file AST line numbers match file line numbers exactly', () => {
|
|
41
|
+
const vueContent = [
|
|
42
|
+
'<template>',
|
|
43
|
+
' <div>',
|
|
44
|
+
' <p>Line 3</p>',
|
|
45
|
+
' <p>Line 4</p>',
|
|
46
|
+
' <p>Line 5</p>',
|
|
47
|
+
' <p>Line 6</p>',
|
|
48
|
+
' <p>Line 7</p>',
|
|
49
|
+
' <p>Line 8</p>',
|
|
50
|
+
' <p>Line 9</p>',
|
|
51
|
+
' <p>Line 10</p>',
|
|
52
|
+
' </div>',
|
|
53
|
+
'</template>',
|
|
54
|
+
'',
|
|
55
|
+
'<script setup lang="ts">',
|
|
56
|
+
'const cond = a && b && c;',
|
|
57
|
+
'</script>'
|
|
58
|
+
].join('\n');
|
|
59
|
+
|
|
60
|
+
const recorded = [];
|
|
61
|
+
const fakeReg = { record: (type, sig, loc) => recorded.push({ type, sig, loc }) };
|
|
62
|
+
auditCode(vueContent, 'SampleVue.vue', 'SampleVue.vue', { patternRegistry: fakeReg });
|
|
63
|
+
|
|
64
|
+
const condRec = recorded.find((r) => r.type === 'PREDICATE_LOGIC');
|
|
65
|
+
assert.ok(condRec, 'Expected PREDICATE_LOGIC recorded');
|
|
66
|
+
assert.strictEqual(condRec.loc.line, 15, 'Expected condition on line 15');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('Friction 4: HOOK_SATURATION excludes canonical controller return wrappers', () => {
|
|
70
|
+
const hookCode = [
|
|
71
|
+
'export function useMyComposable() {',
|
|
72
|
+
' const a = useA();',
|
|
73
|
+
' const b = useB();',
|
|
74
|
+
' const c = useC();',
|
|
75
|
+
' const d = useD();',
|
|
76
|
+
' const e = useE();',
|
|
77
|
+
' return useSharedStatusController({ a, b, c, d, e });',
|
|
78
|
+
'}'
|
|
79
|
+
].join('\n');
|
|
80
|
+
|
|
81
|
+
const hookViolations = auditCode(hookCode, 'useMyComposable.ts', 'src/useMyComposable.ts');
|
|
82
|
+
const hookSat = hookViolations.filter((v) => v.rule === 'HOOK_SATURATION');
|
|
83
|
+
assert.strictEqual(hookSat.length, 0, 'useSharedStatusController should not trigger HOOK_SATURATION');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('Friction 5: TEST_MISSING_COLOCATED is gated by profile', () => {
|
|
87
|
+
const compCode = '<template><button>Click</button></template>';
|
|
88
|
+
|
|
89
|
+
const defaultViolations = auditCode(compCode, 'src/components/molecules/m-button.vue', 'src/components/molecules/m-button.vue', { config: { profile: 'pragmatic' } });
|
|
90
|
+
const testMissingDefault = defaultViolations.filter((v) => v.rule === 'TEST_MISSING_COLOCATED');
|
|
91
|
+
assert.strictEqual(testMissingDefault.length, 0, 'Pragmatic profile should not flag TEST_MISSING_COLOCATED');
|
|
92
|
+
|
|
93
|
+
const strictViolations = auditCode(compCode, 'src/components/molecules/m-button.vue', 'src/components/molecules/m-button.vue', { config: { profile: 'atomic-strict' } });
|
|
94
|
+
const testMissingStrict = strictViolations.filter((v) => v.rule === 'TEST_MISSING_COLOCATED');
|
|
95
|
+
assert.strictEqual(testMissingStrict.length, 1, 'Atomic-strict profile should flag TEST_MISSING_COLOCATED');
|
|
96
|
+
});
|
package/cli/mcp/manifests.js
CHANGED
|
@@ -303,10 +303,15 @@ export const SUB_TOOLS = [
|
|
|
303
303
|
properties: {
|
|
304
304
|
path: { type: 'string', description: 'Target file path' },
|
|
305
305
|
targetContent: { type: 'string', description: 'Exact string chunk to replace' },
|
|
306
|
+
target: { type: 'string', description: 'Alias for target text chunk to replace' },
|
|
307
|
+
search: { type: 'string', description: 'Alias for target text chunk to replace' },
|
|
306
308
|
replacementContent: { type: 'string', description: 'New replacement content' },
|
|
307
|
-
|
|
309
|
+
replacement: { type: 'string', description: 'Alias for replacement content' },
|
|
310
|
+
replace: { type: 'string', description: 'Alias for replacement content' },
|
|
311
|
+
allowMultiple: { type: 'boolean', description: 'Allow multiple replacements' },
|
|
312
|
+
multiple: { type: 'boolean', description: 'Alias for allowMultiple' }
|
|
308
313
|
},
|
|
309
|
-
required: ['path'
|
|
314
|
+
required: ['path']
|
|
310
315
|
}
|
|
311
316
|
},
|
|
312
317
|
{
|
package/cli/mcp/tools-patch.js
CHANGED
|
@@ -2,6 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import { syncSingleFileIndex } from '../search.js';
|
|
3
3
|
import { patchFile } from '../patcher.js';
|
|
4
4
|
import { handleCheckCommand } from '../search-commands.js';
|
|
5
|
+
import { resolveSafePath } from '../path-scope.js';
|
|
5
6
|
|
|
6
7
|
export const isSevereViolation = (v) => {
|
|
7
8
|
const isCritical = v.severity === 'CRITICAL';
|
|
@@ -36,10 +37,10 @@ export const handleChemxPatch = (args = {}, cwd = process.cwd()) => {
|
|
|
36
37
|
const hasRequiredArgs = hasPath && hasTargetContent && hasReplacementContent;
|
|
37
38
|
|
|
38
39
|
if (!hasRequiredArgs) {
|
|
39
|
-
throw new Error('chemx_patch requires "path", "targetContent", and "replacementContent"
|
|
40
|
+
throw new Error('chemx_patch requires "path", target content ("targetContent", "target", or "search"), and replacement content ("replacementContent", "replacement", or "replace").');
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
const targetPath =
|
|
43
|
+
const targetPath = resolveSafePath(args.path, cwd);
|
|
43
44
|
const result = patchFile(targetPath, {
|
|
44
45
|
targetContent,
|
|
45
46
|
replacementContent,
|
|
@@ -69,6 +70,6 @@ export const handleChemxCheck = (args = {}, cwd = process.cwd()) => {
|
|
|
69
70
|
if (!args.path) {
|
|
70
71
|
throw new Error('chemx_check requires "path" argument.');
|
|
71
72
|
}
|
|
72
|
-
const targetPath =
|
|
73
|
+
const targetPath = resolveSafePath(args.path, cwd);
|
|
73
74
|
return handleCheckCommand(targetPath, { isJson: true, isCli: false });
|
|
74
75
|
};
|
package/cli/mcp/tools-read.js
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from '../search-queries.js';
|
|
7
7
|
import { findSimilarSymbols } from '../search-queries-similar.js';
|
|
8
8
|
import { readTokenOptimized } from '../reader.js';
|
|
9
|
+
import { resolveSafePath } from '../path-scope.js';
|
|
9
10
|
import { EXT_LANG_MAP, resolveTargetCwd } from './tools-search-util.js';
|
|
10
11
|
|
|
11
12
|
const buildConnectionCard = (db, symbol, targetPath) => {
|
|
@@ -72,8 +73,9 @@ export const handleChemxRead = (args = {}, cwd = process.cwd()) => {
|
|
|
72
73
|
if (endLine === undefined && colonMatch[3]) endLine = parseInt(colonMatch[3], 10);
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
const targetPath =
|
|
76
|
+
const targetPath = resolveSafePath(rawPath, targetCwd);
|
|
76
77
|
const res = readTokenOptimized(targetPath, {
|
|
78
|
+
cwd: targetCwd,
|
|
77
79
|
outline: args.outline,
|
|
78
80
|
logic: args.logic,
|
|
79
81
|
template: args.template,
|
|
@@ -6,23 +6,30 @@ import {
|
|
|
6
6
|
getFileLockStatus
|
|
7
7
|
} from '../team/team-db.js';
|
|
8
8
|
import { handleError } from '../errors/index.js';
|
|
9
|
+
import { isPathTraversal } from '../path-scope.js';
|
|
9
10
|
|
|
10
11
|
export const handleChemxTeamLock = async (args = {}, cwd = process.cwd()) => {
|
|
11
12
|
const db = openIndexDb(cwd);
|
|
12
13
|
if (!db) return { error: 'sqlite_unavailable' };
|
|
13
14
|
const action = args.action || 'status';
|
|
14
15
|
|
|
16
|
+
if (args.filePath && isPathTraversal(args.filePath, cwd)) {
|
|
17
|
+
return { error: 'path_traversal' };
|
|
18
|
+
}
|
|
19
|
+
|
|
15
20
|
if (action === 'acquire') {
|
|
16
21
|
return requestFileLock(db, args.filePath, args.agentId, {
|
|
17
22
|
purpose: args.purpose,
|
|
18
|
-
ttlMs: args.ttlMs
|
|
23
|
+
ttlMs: args.ttlMs,
|
|
24
|
+
cwd
|
|
19
25
|
});
|
|
20
26
|
}
|
|
21
27
|
if (action === 'release') {
|
|
22
|
-
return releaseFileLock(db, args.filePath, args.agentId);
|
|
28
|
+
return releaseFileLock(db, args.filePath, args.agentId, { cwd });
|
|
23
29
|
}
|
|
24
30
|
if (action === 'status') {
|
|
25
|
-
const status = getFileLockStatus(db, args.filePath);
|
|
31
|
+
const status = getFileLockStatus(db, args.filePath, { cwd });
|
|
32
|
+
if (!status) return null;
|
|
26
33
|
return {
|
|
27
34
|
lease: status.lease,
|
|
28
35
|
waiters: toColumnar(status.waiters, ['id', 'agent_id', 'priority', 'status', 'requested_at'])
|
|
@@ -12,7 +12,10 @@ import { freezeReleaseTrain } from '../team/team-release-train.js';
|
|
|
12
12
|
export const handleChemxTeamTask = async (args = {}, cwd = process.cwd()) => {
|
|
13
13
|
const db = openIndexDb(cwd);
|
|
14
14
|
if (!db) return { error: 'sqlite_unavailable' };
|
|
15
|
-
|
|
15
|
+
let action = args.action || args.subAction;
|
|
16
|
+
if (!action) {
|
|
17
|
+
action = Boolean(args.title) ? 'add' : 'list';
|
|
18
|
+
}
|
|
16
19
|
|
|
17
20
|
if (action === 'list') {
|
|
18
21
|
const parentId = args.parentId !== undefined ? args.parentId : args.parent;
|
|
@@ -53,7 +56,7 @@ export const handleChemxTeamTask = async (args = {}, cwd = process.cwd()) => {
|
|
|
53
56
|
const parentId = args.parentId !== undefined ? args.parentId : args.parent;
|
|
54
57
|
const task = createTask(db, {
|
|
55
58
|
title: args.title,
|
|
56
|
-
target_path: args.targetPath,
|
|
59
|
+
target_path: args.targetPath || args.target,
|
|
57
60
|
tier: args.tier || 'molecule',
|
|
58
61
|
priority: args.priority || 2,
|
|
59
62
|
assigned_agent_id: args.assignedAgentId || null,
|
package/cli/mcp/tools-write.js
CHANGED
|
@@ -2,6 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import { syncSingleFileIndex } from '../search.js';
|
|
3
3
|
import { writeFile } from '../patcher.js';
|
|
4
4
|
import { formatPatchWarnings } from './tools-patch.js';
|
|
5
|
+
import { resolveSafePath } from '../path-scope.js';
|
|
5
6
|
|
|
6
7
|
export const handleChemxWrite = (args = {}, cwd = process.cwd()) => {
|
|
7
8
|
const hasPath = Boolean(args.path);
|
|
@@ -12,7 +13,7 @@ export const handleChemxWrite = (args = {}, cwd = process.cwd()) => {
|
|
|
12
13
|
throw new Error('chemx_write requires "path" and "content" arguments.');
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
const targetPath =
|
|
16
|
+
const targetPath = resolveSafePath(args.path, cwd);
|
|
16
17
|
const result = writeFile(targetPath, {
|
|
17
18
|
content: args.content,
|
|
18
19
|
cwd
|
package/cli/mcp/tools.js
CHANGED
|
@@ -76,10 +76,53 @@ const parseCommand = (command, params) => {
|
|
|
76
76
|
}
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
|
+
if (subCmd === 'patch') {
|
|
80
|
+
return {
|
|
81
|
+
action: 'patch',
|
|
82
|
+
params: {
|
|
83
|
+
path: parts[1] || params?.path,
|
|
84
|
+
target: params?.target || params?.targetContent || params?.search,
|
|
85
|
+
replacement: params?.replacement || params?.replacementContent || params?.replace,
|
|
86
|
+
...params
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
79
90
|
if (subCmd === 'q' || subCmd === 'search') return { action: 'q', params: { query: parts.slice(1).join(' '), ...params } };
|
|
80
91
|
if (subCmd === 'team') {
|
|
81
92
|
const TEAM_ACTIONS = { status: 'team_status', feed: 'team_feed', task: 'team_task', lock: 'team_lock', inbox: 'team_inbox', dm: 'team_dm' };
|
|
82
|
-
|
|
93
|
+
const sub = parts[1] || 'status';
|
|
94
|
+
const action = TEAM_ACTIONS[sub] || 'team_task';
|
|
95
|
+
const parsedParams = { ...params };
|
|
96
|
+
const isTaskSub = sub === 'task' || !TEAM_ACTIONS[parts[1]];
|
|
97
|
+
if (isTaskSub) {
|
|
98
|
+
const taskSub = TEAM_ACTIONS[parts[1]] ? parts[2] : parts[1];
|
|
99
|
+
const isCreate = ['add', 'create', 'new'].includes(taskSub);
|
|
100
|
+
if (isCreate) {
|
|
101
|
+
parsedParams.subAction = 'add';
|
|
102
|
+
const titleTokens = parts.slice(TEAM_ACTIONS[parts[1]] ? 3 : 2);
|
|
103
|
+
if (titleTokens.length > 0 && !parsedParams.title) {
|
|
104
|
+
parsedParams.title = titleTokens.join(' ').replace(/^["']|["']$/g, '');
|
|
105
|
+
}
|
|
106
|
+
} else if (taskSub) {
|
|
107
|
+
parsedParams.subAction = taskSub;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { action, params: parsedParams };
|
|
111
|
+
}
|
|
112
|
+
if (subCmd === 'team_task') {
|
|
113
|
+
const parsedParams = { ...params };
|
|
114
|
+
const taskSub = parts[1];
|
|
115
|
+
const isCreate = ['add', 'create', 'new'].includes(taskSub);
|
|
116
|
+
if (isCreate) {
|
|
117
|
+
parsedParams.subAction = 'add';
|
|
118
|
+
const titleTokens = parts.slice(2);
|
|
119
|
+
if (titleTokens.length > 0 && !parsedParams.title) {
|
|
120
|
+
parsedParams.title = titleTokens.join(' ').replace(/^["']|["']$/g, '');
|
|
121
|
+
}
|
|
122
|
+
} else if (taskSub) {
|
|
123
|
+
parsedParams.subAction = taskSub;
|
|
124
|
+
}
|
|
125
|
+
return { action: 'team_task', params: parsedParams };
|
|
83
126
|
}
|
|
84
127
|
if (subCmd === 'project' || subCmd === 'coordinator') {
|
|
85
128
|
const subAction = parts[1] || 'status';
|
package/cli/patcher.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { ANSI } from './theme.js';
|
|
4
4
|
import { syncSingleFileIndex } from './search.js';
|
|
5
5
|
import { auditFile } from './audit.js';
|
|
6
|
+
import { resolveSafePath } from './path-scope.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Surgically applies a search-and-replace block to a file.
|
|
@@ -37,7 +38,7 @@ export const patchFile = (targetPath, params = {}) => {
|
|
|
37
38
|
throw new Error('replacementContent is required for patching');
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
const resolvedPath =
|
|
41
|
+
const resolvedPath = resolveSafePath(targetPath, cwd);
|
|
41
42
|
if (!fs.existsSync(resolvedPath)) {
|
|
42
43
|
throw new Error(`File not found: ${targetPath}`);
|
|
43
44
|
}
|
|
@@ -253,7 +254,7 @@ export const writeFile = (targetPath, params = {}) => {
|
|
|
253
254
|
throw new Error('content is required for writeFile');
|
|
254
255
|
}
|
|
255
256
|
|
|
256
|
-
const resolvedPath =
|
|
257
|
+
const resolvedPath = resolveSafePath(targetPath, cwd);
|
|
257
258
|
const dir = path.dirname(resolvedPath);
|
|
258
259
|
if (!fs.existsSync(dir)) {
|
|
259
260
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
};
|
|
@@ -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}`);
|
|
@@ -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
|
};
|