mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(review): validate skill roots at mount and isolate provider default roots
ds-review-bot round 1 on the repository-plugin runtime: - a manifest-declared skill root absent or non-directory in the installed package now fails the plugin load (skill-local treats a missing root as legitimately empty, which silently mounted a skill-less plugin) - includeDefaultRoots: false no longer inherits $DSH_BUNDLED_SKILL_DIR, so isolated repository providers see only their explicit roots - prepared wrapper baseUrl schema requires the file: scheme, failing hostile URLs at the declared validation boundary - preparedPath reuses format.ts's isOutside; SERVER_NAME_PATTERN is exported and pinned equal to dsh-mcp-client's, with the restatement justified (the prepare bin keeps a zod-only module graph); the unexplained `as never` cast now carries its schemastery rationale - the import-free wrapper assertion also rejects dynamic import( - the headless fixture wrapper is regenerated by the real prepareDshPlugin and a drift test pins fixture == generator output - prepareDshPlugin JSDoc states the non-atomic publish repair contract
This commit is contained in:
@@ -31,7 +31,10 @@ const preparedManifestSchema = z.object({
|
||||
mcpServers: z.string().min(1).optional(),
|
||||
}).strict()
|
||||
const preparedConfigSchema = z.object({
|
||||
baseUrl: z.url(),
|
||||
// Wrappers pass import.meta.url, which is always file: for an installed
|
||||
// package; any other scheme would only fail later inside fileURLToPath with
|
||||
// an uncontextualized TypeError, so reject it at this validation boundary.
|
||||
baseUrl: z.url({ protocol: /^file$/ }),
|
||||
manifest: preparedManifestSchema,
|
||||
}).strict()
|
||||
|
||||
@@ -70,7 +73,14 @@ export function parsePreparedPluginConfig(value: unknown): PreparedPluginConfig
|
||||
}
|
||||
}
|
||||
|
||||
function isOutside(root: string, candidate: string): boolean {
|
||||
/**
|
||||
* Whether `candidate` resolves outside `root` — the containment check shared
|
||||
* by prepare-time asset copying and runtime prepared-path resolution.
|
||||
* @param root - directory that must contain the candidate.
|
||||
* @param candidate - absolute path to test.
|
||||
* @returns true when the candidate escapes the root.
|
||||
*/
|
||||
export function isOutside(root: string, candidate: string): boolean {
|
||||
const path = relative(root, candidate)
|
||||
/* v8 ignore next -- Different-drive Windows relative paths cannot be produced on POSIX coverage hosts. */
|
||||
return path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path)
|
||||
@@ -95,11 +105,22 @@ async function sourcePath(pluginDirectory: string, sourceRoot: string, configure
|
||||
}
|
||||
|
||||
function wrapperSource(manifest: PreparedPluginManifest): string {
|
||||
// The manifest is static, so the wrapper's service dependencies are too:
|
||||
// declaring them gates the wrapper fiber until the composition provides
|
||||
// them, which means the runtime's SkillLocal/McpClient children activate
|
||||
// within the wrapper's own load epoch and their failures (duplicate
|
||||
// provider names, damaged packages) reject the wrapper's Loader
|
||||
// transaction instead of leaving a silently PENDING or FAILED child.
|
||||
const inject = [
|
||||
'loader',
|
||||
...manifest.skills.length > 0 ? ['skills'] : [],
|
||||
...manifest.mcpServers === undefined ? [] : ['tools'],
|
||||
]
|
||||
return [
|
||||
'// Generated by dsh-plugin-prepare. Do not edit.',
|
||||
`const manifest = ${JSON.stringify(manifest)}`,
|
||||
`export const name = ${JSON.stringify(manifest.name)}`,
|
||||
"export const inject = ['loader']",
|
||||
`export const inject = ${JSON.stringify(inject)}`,
|
||||
'export async function apply(ctx) {',
|
||||
` const runtime = ctx.loader.builtins[${JSON.stringify(REPOSITORY_PLUGIN_BUILTIN)}]`,
|
||||
` if (runtime === undefined) throw new Error(${JSON.stringify(`missing Cordis builtin ${REPOSITORY_PLUGIN_BUILTIN}`)})`,
|
||||
@@ -111,6 +132,10 @@ function wrapperSource(manifest: PreparedPluginManifest): string {
|
||||
|
||||
/**
|
||||
* Validate and package one `.dsh-plugin` directory into static assets plus a fixed wrapper.
|
||||
* Outputs are staged and committed by rename, but the final publish (remove
|
||||
* old outputs, rename assets, rename entry) is not one atomic step: a crash
|
||||
* mid-publish can leave assets without an entry or neither. Rerunning prepare
|
||||
* repairs the package; partial outputs are never importable as a plugin.
|
||||
* @param directory - `.dsh-plugin` package directory; defaults to the prepare process cwd.
|
||||
* @returns the generated static manifest.
|
||||
*/
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* @module @deepseek-ai/dsh-repository-plugin
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
|
||||
import { readFile, stat } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from 'cordis'
|
||||
import type {} from '@cordisjs/plugin-loader'
|
||||
@@ -12,6 +12,7 @@ import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
|
||||
import * as McpClient from '@deepseek-ai/dsh-mcp-client'
|
||||
import {
|
||||
REPOSITORY_PLUGIN_BUILTIN,
|
||||
isOutside,
|
||||
parsePreparedPluginConfig,
|
||||
type PreparedPluginConfig,
|
||||
} from './format.ts'
|
||||
@@ -34,24 +35,42 @@ function preparedPath(baseUrl: string, configured: string): string {
|
||||
if (isAbsolute(configured)) throw new Error(`prepared DSH plugin path must be relative: ${JSON.stringify(configured)}`)
|
||||
const directory = dirname(fileURLToPath(baseUrl))
|
||||
const path = resolve(directory, configured)
|
||||
const rel = relative(directory, path)
|
||||
/* v8 ignore next -- Different-drive Windows relative paths cannot be produced on POSIX coverage hosts. */
|
||||
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
||||
if (isOutside(directory, path)) {
|
||||
throw new Error(`prepared DSH plugin path escapes its package: ${JSON.stringify(configured)}`)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
async function preparedDirectory(baseUrl: string, configured: string): Promise<string> {
|
||||
const path = preparedPath(baseUrl, configured)
|
||||
// A manifest-declared skill root missing from the installed package (files/
|
||||
// .npmignore dropping generated outputs, a damaged cache entry) must fail
|
||||
// the plugin load: the skill provider treats an absent root as legitimately
|
||||
// empty, which would silently mount a skill-less plugin.
|
||||
let info
|
||||
try {
|
||||
info = await stat(path)
|
||||
} catch (cause) {
|
||||
throw new Error(`prepared DSH plugin skill root is missing from the installed package: ${JSON.stringify(configured)}`, { cause })
|
||||
}
|
||||
if (!info.isDirectory()) {
|
||||
throw new Error(`prepared DSH plugin skill root is not a directory: ${JSON.stringify(configured)}`)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
async function applyPrepared(ctx: Context, value: PreparedPluginConfig): Promise<void> {
|
||||
const config = parsePreparedPluginConfig(value)
|
||||
const directory = dirname(fileURLToPath(config.baseUrl))
|
||||
const skillDirectories = config.manifest.skills.map(path => preparedPath(config.baseUrl, path))
|
||||
const skillDirectories = await Promise.all(config.manifest.skills.map(path => preparedDirectory(config.baseUrl, path)))
|
||||
const mcpConfigs = config.manifest.mcpServers === undefined
|
||||
? []
|
||||
: resolveMcpServers(
|
||||
parseMcpDocument(await readFile(preparedPath(config.baseUrl, config.manifest.mcpServers), 'utf8')),
|
||||
process.env,
|
||||
directory,
|
||||
// Schemastery call signatures collapse the parameter to `never` under
|
||||
// NodeNext; ResolvedMcpServer is shaped for the Config union by design.
|
||||
).map(input => McpClient.Config(input as never))
|
||||
|
||||
await ctx.effect(async function* () {
|
||||
|
||||
@@ -5,7 +5,14 @@
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/
|
||||
/**
|
||||
* Restates dsh-mcp-client's `SERVER_NAME_PATTERN` rather than importing it:
|
||||
* the prepare bin must stay a zod-only module graph (no tools seam, no MCP
|
||||
* SDK). Exported so `repository-plugin.spec.ts` pins equality with the
|
||||
* client's exported pattern — prepare-time validation cannot drift from the
|
||||
* registry that enforces uniqueness.
|
||||
*/
|
||||
export const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/
|
||||
const ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
const PLACEHOLDER_PATTERN = /\$\{([^}]*)\}/g
|
||||
|
||||
@@ -89,7 +96,7 @@ export function parseMcpDocument(content: string): McpDocument {
|
||||
if (!result.success) throw new Error(`invalid .mcp.json:\n${z.prettifyError(result.error)}`)
|
||||
for (const [serverName, definition] of Object.entries(result.data.mcpServers)) {
|
||||
if (!SERVER_NAME_PATTERN.test(serverName)) {
|
||||
throw new Error(`invalid .mcp.json: server name ${JSON.stringify(serverName)} must match [A-Za-z0-9_-]{1,32}`)
|
||||
throw new Error(`invalid .mcp.json: server name ${JSON.stringify(serverName)} must match ${SERVER_NAME_PATTERN.source}`)
|
||||
}
|
||||
visitStrings(serverName, definition, assertTemplate)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user