Skip to content

Commit fd8c228

Browse files
committed
fix: close mcp cli scan review gaps
1 parent 7aac5f3 commit fd8c228

16 files changed

Lines changed: 299 additions & 112 deletions

‎grok/plugins/devcodex-workspace/mcp/workspace-bridge.cjs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,13 @@ child.stdout.pipe(process.stdout)
5555
child.stderr.pipe(process.stderr)
5656
child.on('error', error => {
5757
process.stderr.write(`DevCodex Grok MCP bridge: failed to start ${kind} server: ${error.message}\n`)
58-
process.exitCode = 2
58+
process.exit(2)
5959
})
6060
child.on('exit', (code, signal) => {
6161
if (signal) {
6262
process.stderr.write(`DevCodex Grok MCP bridge: ${kind} server ended by ${signal}.\n`)
63-
process.exitCode = 1
63+
process.exit(1)
6464
return
6565
}
66-
process.exitCode = Number.isInteger(code) ? code : 1
66+
process.exit(Number.isInteger(code) ? code : 1)
6767
})

‎grok/plugins/devcodex-workspace/skills/devcodex-workspace/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,6 @@ Platform facts (Grok Build hooks docs): `PreToolUse` and `Stop`/`SubagentStop` c
5858
| report-memory | Non-chat write report + memory |
5959
| honest-ceiling | No inject / forced Stop loops / Grok===Codex claims; preserve pending facts and continue available work |
6060

61-
Machine source: `scripts/lib/host-parity-scorecard.js` (`GROK_TURN_EXECUTION_CHECKLIST`, `classifyWorkspaceRootScanSample`, `classifyTtfvOmissionSample`, `repairSteps`). Site doc: `website/docs/intro/host-parity-grok.md`.
61+
Machine source: `scripts/lib/host-parity-scorecard.js` (`GROK_TURN_EXECUTION_CHECKLIST`, `classifyWorkspaceRootScanSample`, `classifyTtfvOmissionSample`, `repairSteps`). Site doc: `public-site/docs/reference/hosts.md`.
6262

6363
The plugin is a discovery adapter, not a second rules source. Its passive Hook output must never be presented as kernel-injection evidence. Do not copy `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `.agents`, `.github`, `.grok`, `.codex`, `.claude`, or `.gemini` into a workspace.

‎hooks/_runtime/lifecycle-stop-gate.cjs‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,10 @@ try {
3434
classifyEcrClosure
3535
} = require('../../scripts/lib/process-enforcement.js'))
3636
} catch {
37-
classifyReviewChecklistCompletion = () => ({ ok: true, code: null, gap: null })
38-
classifyProcessArtifactCompleteness = () => ({ ok: true, code: null, gap: null, missing: [] })
39-
classifyDeliveryHonesty = () => ({ ok: true, gaps: [], code: null, gap: null })
40-
classifyEcrClosure = () => ({ ok: true, code: null, gap: null })
37+
classifyReviewChecklistCompletion = () => ({ ok: false, code: 'PROCESS_ENFORCEMENT_UNAVAILABLE', gap: 'process-enforcement-unavailable' })
38+
classifyProcessArtifactCompleteness = () => ({ ok: false, code: 'PROCESS_ENFORCEMENT_UNAVAILABLE', gap: 'process-enforcement-unavailable', missing: ['process-enforcement'] })
39+
classifyDeliveryHonesty = () => ({ ok: false, gaps: ['process-enforcement-unavailable'], code: 'PROCESS_ENFORCEMENT_UNAVAILABLE', gap: 'process-enforcement-unavailable' })
40+
classifyEcrClosure = () => ({ ok: false, code: 'PROCESS_ENFORCEMENT_UNAVAILABLE', gap: 'process-enforcement-unavailable' })
4141
}
4242

4343
function extractLastAssistantMessage (payload) {

‎hooks/_runtime/lifecycle.cjs‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ const CONTINUATION_TASK_MEMORY_MAX_BYTES = 16 * 1024
184184
const CONTINUATION_AGENT_MEMORY_MAX_BYTES = 64 * 1024
185185
const CONTINUATION_EVIDENCE_FILE_LIMIT = 5
186186
const CONTINUATION_EVIDENCE_QUERY_LIMIT = 5
187+
const HOOK_STDIN_MAX_BYTES = 4 * 1024 * 1024
187188

188189
// ─── CP Gate constants ────────────────────────────────────────────────────────
189190
const CP3_RUNTIME_FILE_THRESHOLD = 5
@@ -224,7 +225,14 @@ function readStdin() {
224225
return new Promise((resolve, reject) => {
225226
let buf = ''
226227
process.stdin.setEncoding('utf8')
227-
process.stdin.on('data', c => { buf += c })
228+
process.stdin.on('data', c => {
229+
buf += c
230+
if (Buffer.byteLength(buf, 'utf8') > HOOK_STDIN_MAX_BYTES) {
231+
const error = new Error(`DevCodex lifecycle stdin exceeds ${HOOK_STDIN_MAX_BYTES} bytes`)
232+
error.code = 'DEVCODEX_LIFECYCLE_STDIN_TOO_LARGE'
233+
reject(error)
234+
}
235+
})
228236
process.stdin.on('end', () => resolve(buf))
229237
process.stdin.on('error', reject)
230238
})

‎mcp/hot-reload-supervisor.cjs‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const RECEIPT_SCHEMA = 'GlobalHostConfigReceiptV1'
99
const ROLES = new Set(['memory', 'profile'])
1010
const REQUEST_TIMEOUT_MS = 30_000
1111
const RELOAD_SETTLE_MS = 80
12+
const MAX_PAGED_LIST_PAGES = 128
1213

1314
function supervisorError (code, detail) {
1415
const error = new Error(`${code}: ${detail}`)
@@ -44,8 +45,9 @@ function validLiveSourceRoot (candidate, role) {
4445
const root = path.resolve(candidate)
4546
try {
4647
const pkg = readJson(path.join(root, 'package.json'), 'live source package')
48+
const gitStat = fs.statSync(path.join(root, '.git'))
4749
return pkg.name === 'devcodex' &&
48-
fs.statSync(path.join(root, '.git')).isDirectory() &&
50+
(gitStat.isDirectory() || gitStat.isFile()) &&
4951
fs.statSync(path.join(root, 'mcp', `${role}-server.js`)).isFile()
5052
} catch {
5153
return false
@@ -123,6 +125,7 @@ function createWorker (server, inputRoot) {
123125
readline.createInterface({ input: child.stdout }).on('line', line => {
124126
let message
125127
try { message = JSON.parse(line) } catch { return }
128+
if (!message || typeof message !== 'object' || Array.isArray(message)) return
126129
const waiter = pending.get(message.id)
127130
if (waiter) {
128131
pending.delete(message.id)
@@ -180,11 +183,24 @@ function createWorker (server, inputRoot) {
180183
async function collectPagedList (worker, method, field) {
181184
const values = []
182185
let cursor
186+
const seenCursors = new Set()
187+
let pages = 0
183188
do {
189+
pages += 1
190+
if (pages > MAX_PAGED_LIST_PAGES) {
191+
throw supervisorError('MCP_SUPERVISOR_PAGING_LIMIT_EXCEEDED', `${method}: exceeded ${MAX_PAGED_LIST_PAGES} pages`)
192+
}
184193
const response = await worker.exchange(method, cursor ? { cursor } : {})
185194
if (response.error) throw supervisorError('MCP_SUPERVISOR_CONTRACT_READ_FAILED', `${method}: ${response.error.message}`)
186195
values.push(...(Array.isArray(response.result?.[field]) ? response.result[field] : []))
187-
cursor = response.result?.nextCursor || null
196+
const nextCursor = response.result?.nextCursor || null
197+
if (nextCursor) {
198+
if (seenCursors.has(nextCursor)) {
199+
throw supervisorError('MCP_SUPERVISOR_PAGING_CURSOR_LOOP', `${method}: repeated nextCursor`)
200+
}
201+
seenCursors.add(nextCursor)
202+
}
203+
cursor = nextCursor
188204
} while (cursor)
189205
return values.sort((left, right) => String(left?.name || '').localeCompare(String(right?.name || '')))
190206
}

‎mcp/memory-server.js‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const crypto = require('crypto')
3232
const fs = require('fs')
3333
const os = require('os')
3434
const path = require('path')
35-
const { assertSingleSegment, resolveInside, resolveExistingRegularFileInside } = require('./path-guard')
35+
const { assertSingleSegment, resolveInside, resolveExistingRegularFileInside, resolveWritePathInside } = require('./path-guard')
3636
const { createJsonLineServer } = require('./stdio-jsonrpc.cjs')
3737
const { createMemoryFileTransaction } = require('./memory-file-transaction.cjs')
3838
const {
@@ -982,7 +982,7 @@ function sessionFilePath(agent, date, args = {}) {
982982
: assertSingleSegment(agent, 'agent')
983983
const safeAgent = normalizeAgent(candidate)
984984
if (!safeAgent) throw new Error('invalid agent')
985-
return resolveInside(getActiveRoot(args), '.memory', 'clients', safeAgent, 'tasks', `${date || today()}.md`)
985+
return resolveWritePathInside(getActiveRoot(args), '.memory', 'clients', safeAgent, 'tasks', `${date || today()}.md`)
986986
}
987987

988988
function summaryFilePath(agent, args = {}) {
@@ -991,7 +991,7 @@ function summaryFilePath(agent, args = {}) {
991991
: assertSingleSegment(agent, 'agent')
992992
const safeAgent = normalizeAgent(candidate)
993993
if (!safeAgent) throw new Error('invalid agent')
994-
return resolveInside(getActiveRoot(args), '.memory', 'clients', safeAgent, 'SUMMARY.md')
994+
return resolveWritePathInside(getActiveRoot(args), '.memory', 'clients', safeAgent, 'SUMMARY.md')
995995
}
996996

997997
function summaryProjectLabel(args = {}) {
@@ -1019,7 +1019,7 @@ function renderSummaryRow(cells) {
10191019
}
10201020

10211021
function taskSessionsPath(kind, requirement, args = {}) {
1022-
return resolveInside(getActiveRoot(args), kind, assertSingleSegment(requirement, 'requirement'), '.memory', 'sessions.md')
1022+
return resolveWritePathInside(getActiveRoot(args), kind, assertSingleSegment(requirement, 'requirement'), '.memory', 'sessions.md')
10231023
}
10241024

10251025
const CP_HEADING_RE = /^#{1,6}\s+.*CP\s*确认记录\s*$/i
@@ -1445,7 +1445,7 @@ function memoryLockDir(target, filePath) {
14451445
.createHash('sha256')
14461446
.update(`${canonicalMemoryPath(target.activeRoot)}\0${canonicalMemoryPath(filePath)}`)
14471447
.digest('hex')
1448-
return resolveInside(resolveRuntimeStateRoot(target.activeRoot, target.project).root, 'memory-locks', key)
1448+
return resolveWritePathInside(resolveRuntimeStateRoot(target.activeRoot, target.project).root, 'memory-locks', key)
14491449
}
14501450

14511451
const MEMORY_LOCK_LEGACY_STALE_MS = 30 * 60 * 1000

‎mcp/path-guard.js‎

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,45 @@ function assertSingleSegment(value, label) {
1313
}
1414

1515
function resolveInside(root, ...segments) {
16-
const target = path.resolve(root, ...segments)
17-
const relative = path.relative(root, target)
16+
const fsImpl = fs
17+
const rootPath = path.resolve(root)
18+
const target = path.resolve(rootPath, ...segments)
19+
const relative = path.relative(rootPath, target)
1820
if (relative && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)) return target
21+
if (!relative) return target
1922
throw new Error('path escapes allowed root')
2023
}
2124

25+
function realpath(fsImpl, candidate) {
26+
return fsImpl.realpathSync.native
27+
? fsImpl.realpathSync.native(candidate)
28+
: fsImpl.realpathSync(candidate)
29+
}
30+
31+
function assertNoExistingSymlinkTraversal(root, target, options = {}) {
32+
const fsImpl = options.fs || fs
33+
const label = options.label || 'path'
34+
const rootPath = path.resolve(root)
35+
if (!fsImpl.existsSync(rootPath)) return
36+
const canonicalRoot = realpath(fsImpl, rootPath)
37+
const relative = path.relative(rootPath, path.resolve(target))
38+
if (!relative) return
39+
let cursor = rootPath
40+
for (const segment of relative.split(path.sep).filter(Boolean)) {
41+
cursor = path.join(cursor, segment)
42+
if (!fsImpl.existsSync(cursor)) break
43+
const stat = fsImpl.lstatSync(cursor)
44+
if (stat.isSymbolicLink()) {
45+
throw new Error(`invalid ${label}: symbolic links and reparse-point traversals are not allowed`)
46+
}
47+
const canonicalCursor = realpath(fsImpl, cursor)
48+
const canonicalRelative = path.relative(canonicalRoot, canonicalCursor)
49+
if (canonicalRelative === '..' || canonicalRelative.startsWith(`..${path.sep}`) || path.isAbsolute(canonicalRelative)) {
50+
throw new Error(`invalid ${label}: canonical path escapes allowed root`)
51+
}
52+
}
53+
}
54+
2255
function resolveExistingRegularFileInside(root, value, options = {}) {
2356
const fsImpl = options.fs || fs
2457
const label = options.label || 'path'
@@ -36,6 +69,7 @@ function resolveExistingRegularFileInside(root, value, options = {}) {
3669
if (!fsImpl.existsSync(rootPath) || !fsImpl.existsSync(target)) {
3770
throw new Error(`invalid ${label}: file does not exist inside allowed root`)
3871
}
72+
assertNoExistingSymlinkTraversal(rootPath, target, { fs: fsImpl, label })
3973

4074
let cursor = rootPath
4175
for (const segment of segments) {
@@ -46,11 +80,8 @@ function resolveExistingRegularFileInside(root, value, options = {}) {
4680
}
4781
}
4882

49-
const realpath = fsImpl.realpathSync.native
50-
? candidate => fsImpl.realpathSync.native(candidate)
51-
: candidate => fsImpl.realpathSync(candidate)
52-
const canonicalRoot = realpath(rootPath)
53-
const canonicalTarget = realpath(target)
83+
const canonicalRoot = realpath(fsImpl, rootPath)
84+
const canonicalTarget = realpath(fsImpl, target)
5485
const canonicalRelative = path.relative(canonicalRoot, canonicalTarget)
5586
if (!canonicalRelative || canonicalRelative === '..' || canonicalRelative.startsWith(`..${path.sep}`) || path.isAbsolute(canonicalRelative)) {
5687
throw new Error(`invalid ${label}: canonical path escapes allowed root`)
@@ -61,4 +92,10 @@ function resolveExistingRegularFileInside(root, value, options = {}) {
6192
return canonicalTarget
6293
}
6394

64-
module.exports = { assertSingleSegment, resolveInside, resolveExistingRegularFileInside }
95+
function resolveWritePathInside(root, ...segments) {
96+
const target = resolveInside(root, ...segments)
97+
assertNoExistingSymlinkTraversal(root, target)
98+
return target
99+
}
100+
101+
module.exports = { assertSingleSegment, resolveInside, resolveExistingRegularFileInside, resolveWritePathInside }

‎mcp/stdio-jsonrpc.cjs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ function withDeadline(value, timeoutMs) {
3535
]).finally(() => clearTimeout(timer))
3636
}
3737

38+
function isPlainObject(value) {
39+
return value !== null && typeof value === 'object' && !Array.isArray(value)
40+
}
41+
3842
function createJsonLineServer(options = {}) {
3943
const input = options.input || process.stdin
4044
const output = options.output || process.stdout
@@ -94,6 +98,14 @@ function createJsonLineServer(options = {}) {
9498
sendError(null, -32700, 'Parse error')
9599
return
96100
}
101+
if (!isPlainObject(request)) {
102+
sendError(null, -32600, 'Invalid Request')
103+
return
104+
}
105+
if (typeof request.method !== 'string' || !request.method.trim()) {
106+
sendError(request.id === undefined ? null : request.id, -32600, 'Invalid Request')
107+
return
108+
}
97109
try {
98110
const result = await withDeadline(
99111
Promise.resolve().then(() => dispatch(request.method, request.params)),

‎public-site/docs/reference/hosts.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ devcodex grok
4444

4545
当前目标拓扑是 `devcodex-workspace` plugin 独占六个 DevCodex lifecycle 事件;用户级 Grok Hook 文件只保留委派说明和用户自己的 Hook。Grok 会精确去重完全相同的 handler,但“双声明碰巧相同”仍不是稳定所有权。无需在 `~/.grok/config.toml` 中关闭整个 Claude 兼容层,也不要把关闭 Hooks 当作产品修复。
4646

47-
Grok bridge 在 adapter 缺失、失败、输出无效或 workspace 外时采用 allow/degraded,并只把命令风险写入本地诊断;它不会用本地正则覆盖 Grok 的权限设置。正常 adapter 返回的 task、project、root、slot 或 retention 工作流阻断仍会透传,这与宿主操作权限是两个独立层次。
47+
Grok bridge 在 adapter 缺失、失败、输出无效、workspace 外,或 adapter 返回 task、project、root、slot、retention 等工作流有效性问题时采用 allow/degraded,并把原因写入本地诊断;它不会用本地正则覆盖 Grok 的权限设置。工作流有效性诊断与宿主操作权限是两个独立层次。
4848

4949
## Cursor:本地形态、Cloud 与命令身份
5050

‎scripts/lib/cli-host-utils.js‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ function buildCliHostUtils({ fs, path, isPlainObject, claudeMcpJson }) {
7676
return String(filePath || '').replace(/\\/g, '/')
7777
}
7878

79+
function sameResolvedPath(left, right) {
80+
const a = path.resolve(left)
81+
const b = path.resolve(right)
82+
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b
83+
}
84+
7985
/**
8086
* Count managed-block markers. Fail-closed requires 0 pairs (will append) or exactly one ordered pair.
8187
* @returns {{ begin: number, end: number, ok: boolean, code: string|null }}
@@ -262,6 +268,8 @@ function buildCliHostUtils({ fs, path, isPlainObject, claudeMcpJson }) {
262268
* profileServerPath: string|null,
263269
* memoryServerExists: boolean,
264270
* profileServerExists: boolean,
271+
* memoryServerMatchesExpected: boolean,
272+
* profileServerMatchesExpected: boolean,
265273
* memoryHasArgs: boolean,
266274
* profileHasArgs: boolean,
267275
* status: 'missing'|'partial'|'stale'|'ok'
@@ -281,6 +289,8 @@ function buildCliHostUtils({ fs, path, isPlainObject, claudeMcpJson }) {
281289
profileServerPath: null,
282290
memoryServerExists: false,
283291
profileServerExists: false,
292+
memoryServerMatchesExpected: false,
293+
profileServerMatchesExpected: false,
284294
memoryHasArgs: false,
285295
profileHasArgs: false,
286296
status: 'missing'
@@ -306,14 +316,18 @@ function buildCliHostUtils({ fs, path, isPlainObject, claudeMcpJson }) {
306316
const profileCandidate = profileArgs[0] ? path.resolve(profileArgs[0]) : null
307317
result.memoryServerExists = Boolean(memoryCandidate && fs.existsSync(memoryCandidate))
308318
result.profileServerExists = Boolean(profileCandidate && fs.existsSync(profileCandidate))
319+
result.memoryServerMatchesExpected = Boolean(memoryCandidate && sameResolvedPath(memoryCandidate, expectedMemory))
320+
result.profileServerMatchesExpected = Boolean(profileCandidate && sameResolvedPath(profileCandidate, expectedProfile))
309321

310322
if (
311323
result.hasDevcodexMemory &&
312324
result.hasDevcodexProfile &&
313325
result.memoryHasArgs &&
314326
result.profileHasArgs &&
315327
result.memoryServerExists &&
316-
result.profileServerExists
328+
result.profileServerExists &&
329+
result.memoryServerMatchesExpected &&
330+
result.profileServerMatchesExpected
317331
) {
318332
result.status = 'ok'
319333
} else if (result.hasDevcodexMemory || result.hasDevcodexProfile || result.hasManagedBlock) {

0 commit comments

Comments
 (0)