Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

## [Unreleased]

- Declare the progressive-disclosure, PTC batch and PTC SDK loader tools in their own `x-tool-namespaces` entry, so the manifest's advertised tool surface matches the namespaces it publishes under, and refuse a confined argv that would start the host Electron binary instead of reporting its exit 0 as success (#484).
- 将渐进披露、PTC batch 与 PTC SDK 加载工具声明为独立的 `x-tool-namespaces` 条目,使 manifest 宣传的工具面与其发布的命名空间一致;受限 argv 若会启动宿主 Electron 二进制,则显式拒绝,不再把它的 exit 0 当成成功(#484)。
- Keep Native file routing separate from the independent Bash prompt section, so resident Native + Shell consumers do not repeat Bash guidance or advertise host PowerShell routing. Keep deployment guidance single-owned across Bash, Native and discovery sections, including withdrawal and reload.
- 将 Native 文件路由提示与独立 Bash 提示段分开,避免 Native + Shell 常驻消费者重复 Bash 指导或把 Bash 宣传为宿主 PowerShell 路由。部署指导在 Bash、Native 与发现提示段之间仅保留一份,并覆盖撤回与重载。
- Update preset hints and rendered settings UI to describe default Bash/PowerShell coexistence; hiding host PowerShell remains an explicit choice.
Expand Down
12 changes: 12 additions & 0 deletions dsh-plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,18 @@
"bash"
],
"shellPermission": "Mode row enabled with the matching preset AND publishBashTool AND authoritative per-session DSH policy; restricted modes require host confinement"
},
{
"serverName": "dsh-ops",
"transport": "in-process",
"source": "the plugin's progressive-disclosure, PTC batch and PTC SDK loader surface",
"publicPrefix": "ops_",
"tools": [
"batch",
"load_sdk",
"discover_tools"
],
"shellPermission": "No command execution of its own: batch is gated by the same per-session DSH policy and root as the file mutations it plans or runs, and load_sdk/discover_tools only select capabilities already published to that session"
}
],
"x-components": [
Expand Down
40 changes: 32 additions & 8 deletions scripts/validate-manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
publicToolName,
renderToolingPolicy,
} from '../lib/policy.js'
import { NATIVE_FILE_TOOLS } from '../lib/capabilities/registry.js'
import { DISCOVERY_TOOL_NAME, NATIVE_FILE_TOOLS, PTC_TOOLS, SDK_CAPABILITIES } from '../lib/capabilities/registry.js'
import { resolvePtcConfig } from '../lib/ptc/index.js'
import { resolveTuiConfig } from '../lib/components/tui/index.js'
import { resolvePanelConfig } from '../lib/components/panel/index.js'
Expand Down Expand Up @@ -295,24 +295,48 @@ const equippedPolicy = renderToolingPolicy({
levels: { files: true, bash: true, pwsh: true },
})

// Every namespace after the first describes tools this plugin registers itself,
// so those names are checked against `IN_PROCESS_TOOLS` and against the policy
// text that has to name them. Written against whatever the manifest declares.
// The routing table is deliberately compact, so only the routed in-process tools
// have to be named by it; a PTC-only API or the SDK loader is never advertised there.
check(
IN_PROCESS_TOOLS.every((toolName) => equippedPolicy.includes(publicToolName(toolName))),
'the tools this plugin registers itself are named by the tooling policy',
`policy never names ${IN_PROCESS_TOOLS.filter((name) => !equippedPolicy.includes(publicToolName(name))).map(publicToolName).join(', ')}`,
)
// Every namespace after the first describes tools this plugin registers itself.
// The names come from the capability catalogue, not from a hard-coded list: the
// shell row, the PTC-only APIs the compact native routing table never advertises,
// the PTC SDK loader and the progressive-disclosure entry.
const ownSurface = new Set([
...IN_PROCESS_TOOLS,
...PTC_TOOLS.filter((toolName) => !NATIVE_FILE_TOOLS.includes(toolName)),
...SDK_CAPABILITIES.map((capability) => capability.rawName),
DISCOVERY_TOOL_NAME,
])
const declaredSurface = new Set(namespaces[0]?.tools ?? [])
for (const [index, entry] of namespaces.slice(1).entries()) {
const declared = Array.isArray(entry?.tools) ? entry.tools : []
const identified = declared.filter((toolName) => typeof toolName === 'string'
&& IN_PROCESS_TOOLS.includes(toolName)
&& equippedPolicy.includes(publicToolName(toolName)))
const identified = declared.filter((toolName) => typeof toolName === 'string' && ownSurface.has(toolName))
check(
declared.length > 0 && identified.length === declared.length,
`every tool of the namespace at index ${index + 1} is published under ${TOOL_PREFIX} by this plugin itself`,
`declared ${JSON.stringify(entry?.tools)} vs in-process ${IN_PROCESS_TOOLS.map(publicToolName).join(', ') || '(none)'}`,
`declared ${JSON.stringify(entry?.tools)} vs own surface ${[...ownSurface].map(publicToolName).join(', ') || '(none)'}`,
)
for (const toolName of declared) {
check(!declaredSurface.has(toolName), `${publicToolName(String(toolName))} is declared by exactly one namespace`)
declaredSurface.add(toolName)
}
}
// The advertised component surface must be the declared namespace surface (#484):
// a name that only reaches x-components[].tools is invisible to a namespace consumer.
for (const component of manifestRaw.contributes?.['x-components'] ?? []) {
for (const name of Array.isArray(component?.tools) ? component.tools : []) {
const raw = typeof name === 'string' && name.startsWith(TOOL_PREFIX) ? name.slice(TOOL_PREFIX.length) : undefined
check(
raw !== undefined && declaredSurface.has(raw),
`${String(name)} is declared by an x-tool-namespaces entry`,
`declared surface ${[...declaredSurface].map(publicToolName).join(', ')}`,
)
}
}

const sections = manifestRaw.contributes?.['x-prompt-sections'] ?? []
Expand Down
50 changes: 50 additions & 0 deletions src/shells/host-runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Refuse a confined argv that would start the host GUI instead of the command.
*
* The Windows ACL sandbox provider builds its invocation as
* `[process.execPath, <runner>, …]`. Under DSH Desktop `process.execPath` is the
* Electron GUI binary, so spawning that argv unchanged opens a second GUI
* instance: the host's single-instance lock makes it exit 0 without ever running
* the command, and an exit-0/no-output result is indistinguishable from success.
* Nothing else in this plugin adapts that argv, so the refusal lives here rather
* than reporting a silent success.
*/

/** The host runtime a confined argv may name. */
export interface HostRuntime {
/** The binary the confined argv may start. */
execPath: string
/** Whether that binary is an Electron application rather than a Node runtime. */
electron: boolean
}

/** The variable Electron reads to behave as a plain Node runtime. */
const NODE_MODE = 'ELECTRON_RUN_AS_NODE'

/**
* The host runtime as this process sees it.
* @returns the exec path and whether it is an Electron application.
*/
export function currentHostRuntime(): HostRuntime {
return { execPath: process.execPath, electron: process.versions.electron !== undefined }
}

/**
* Whether the child environment already asks Electron to behave as Node.
* Windows environment names are case-insensitive, so a lower-cased variant counts.
*/
function nodeModeRequested(env: NodeJS.ProcessEnv): boolean {
return Object.entries(env).some(([key, value]) => key.toUpperCase() === NODE_MODE && value === '1')
}

/**
* Refuse a confined argv that would start the host GUI instead of the command.
* @param argv - the argv the confinement provider returned.
* @param env - the environment the confined child will actually receive.
* @param host - the host runtime; injected so the guard is testable off-Electron.
* @returns the failure text, or `undefined` when the argv is safe to spawn.
*/
export function hostRunnerRefusal(argv: readonly string[], env: NodeJS.ProcessEnv = {}, host: HostRuntime = currentHostRuntime()): string | undefined {
if (!host.electron || argv[0] !== host.execPath || nodeModeRequested(env)) return undefined
return 'EXECUTOR_UNAVAILABLE: the confined argv starts the host Electron binary without ELECTRON_RUN_AS_NODE, so it would open a second GUI instance that exits 0 without running the command. This is not a user authorization denial and no command executed. Restore a confinement provider that returns a Node-runnable argv, or run the confined child with ELECTRON_RUN_AS_NODE=1; changing the DSH selector is not a repair. Do not retry.'
}
15 changes: 13 additions & 2 deletions src/shells/session-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { bashToolDefinition, withBashStatus } from './tool.js'
import { confinedBash } from '../bash-ipc.js'
import { prepareWorkspace } from '../workspace-preparation.js'
import { executionRestriction } from '../permissions.js'
import { shellEnvOverrides } from './constants.js'
import { hostRunnerRefusal } from './host-runner.js'

export function startSessionBash({ ctx, bash, active }: { ctx: ShellContext; bash: { file: string; source: string }; active(): boolean },
args: Record<string, unknown>, exec: ShellCall, standing: ShellPolicy, policy: ShellPolicy, dshEnv: NodeJS.ProcessEnv, options: ExecutionOptions): ShellExecution {
Expand All @@ -23,8 +25,11 @@ export function startSessionBash({ ctx, bash, active }: { ctx: ShellContext; bas
try { runtime = policy.mode === 'danger-full-access' ? { file: bash.file } : confinedBash(bash.file, { source: bash.source }) }
catch { throw new Error('EXECUTOR_UNAVAILABLE: the restricted Bash runtime is unavailable or incompatible; this is not a user authorization denial. Restore the verified pinned Bash payload and IPC runtime; changing the DSH selector is not a repair. Do not retry or bypass confinement.') }
let confinement: ConfinedArgv | undefined
// The child environment is `ENV_OVERRIDES` over these overrides and nothing else,
// so this is also the environment the host-runner guard has to read.
const childEnv = { ...runtime.env, ...dshEnv }
const definition = bashToolDefinition({ file: runtime.file, source: bash.source,
env: { ...runtime.env, ...dshEnv }, subprocess: ctx.get('subprocess'),
env: childEnv, subprocess: ctx.get('subprocess'),
prepareArgv: async (argv: string[], signal: AbortSignal) => {
signal.throwIfAborted()
assertAuthority()
Expand All @@ -38,7 +43,13 @@ export function startSessionBash({ ctx, bash, active }: { ctx: ShellContext; bas
}
signal.throwIfAborted()
assertAuthority()
return confinement?.argv ?? argv
const prepared = confinement?.argv ?? argv
// A confinement provider that hands back the host binary itself — or a bashPath
// pointed at it — would start a second GUI instance under Electron and exit 0
// without running the command. Refuse instead of reporting that as success.
const refusal = hostRunnerRefusal(prepared, shellEnvOverrides(globalThis.process.env, childEnv))
if (refusal) throw new Error(refusal)
return prepared
},
projectResult: (value: ShellResult) => {
const stderr = `${value.stderr?.text ?? ''}\n${value.error ?? ''}`
Expand Down
36 changes: 32 additions & 4 deletions test/manifest.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
publicToolName,
renderToolingPolicy,
} from '../lib/policy.js'
import { NATIVE_FILE_TOOLS } from '../lib/capabilities/registry.js'
import { DISCOVERY_TOOL_NAME, IN_PROCESS_TOOLS, NATIVE_FILE_TOOLS, PTC_TOOLS, SDK_CAPABILITIES } from '../lib/capabilities/registry.js'
import { COMPONENTS, componentEnabled, componentEntry } from '../lib/components/defaults.js'
import { assert, PACKAGE_ROOT, report, test } from './lib/harness.mjs'

Expand Down Expand Up @@ -75,8 +75,17 @@ await test('every declared tool is one the policy actually names', () => {
// The FastCtx background backend was removed: no namespace may still advertise it.
assert.equal(namespaces.some((entry) => entry.serverName === 'fastctx' || entry.transport === 'stdio'), false)

// Every further namespace is this plugin's own in-process surface, so its
// names are checked against the shell capability and against the policy.
// Every further namespace is this plugin's own in-process surface, so its names
// are checked against the capability catalogue rather than a hard-coded list: the
// shell row, the PTC-only APIs the compact native routing table never advertises,
// the PTC SDK loader and the progressive-disclosure entry.
const ownSurface = new Set([
...IN_PROCESS_TOOLS,
...PTC_TOOLS.filter((name) => !NATIVE_FILE_TOOLS.includes(name)),
...SDK_CAPABILITIES.map((capability) => capability.rawName),
DISCOVERY_TOOL_NAME,
])
const declared = new Set()
for (const [offset, entry] of namespaces.slice(1).entries()) {
const index = offset + 1
assert.ok(
Expand All @@ -85,12 +94,31 @@ await test('every declared tool is one the policy actually names', () => {
)
for (const tool of entry.tools) {
assert.ok(
['bash', 'pwsh'].includes(tool),
ownSurface.has(tool),
`the namespace at index ${index} declares ${tool}, which the plugin does not register itself`,
)
assert.equal(declared.has(tool), false, `${tool} is declared by two namespaces`)
declared.add(tool)
}
// The routing table stays compact: it advertises the file, shell and discovery
// rows only, so a PTC-only API or the SDK loader is legitimately absent from it.
for (const tool of entry.tools.filter((name) => IN_PROCESS_TOOLS.includes(name))) {
assert.ok(policy.includes(publicToolName(tool)), `the policy never names ${publicToolName(tool)}`)
}
}
// The declared surface has to cover the names the component advertises (#484): a
// tool that appears only in x-components[].tools is invisible to anything that
// derives the tool surface from the namespaces.
const declaredSurface = new Set([...namespaces[0].tools, ...declared])
for (const component of raw.contributes['x-components']) {
for (const name of component.tools) {
assert.ok(name.startsWith(TOOL_PREFIX), `${name} must carry the public prefix`)
assert.ok(
declaredSurface.has(name.slice(TOOL_PREFIX.length)),
`the manifest advertises ${name} but no x-tool-namespaces entry declares it`,
)
}
}
})

await test('the declared namespace documents the upstream names and the public prefix', () => {
Expand Down
32 changes: 32 additions & 0 deletions test/shells-host.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { assertSupportedJsonSchema, validateJsonSchemaValue } from '@deepseek-ai
import { bashToolDefinition } from '../lib/shells.js'
import { hostBashDefinition } from '../lib/shells/host-tool.js'
import { startSessionBash } from '../lib/shells/session-execution.js'
import { hostRunnerRefusal } from '../lib/shells/host-runner.js'
import { startBashJob, waitOnBashJob } from '../lib/shells/jobs.js'
import { sessionWorkdir } from '../lib/session-shells.js'
import { hostForeground } from '../lib/shells/host-contract.js'
Expand Down Expand Up @@ -354,4 +355,35 @@ await test('timeout cannot claim no effects while an invoked asynchronous starte
assert.equal(stopped, true); assert.equal(jobs.outcome.status, 'killed')
})

await test('a confined argv that starts the host Electron binary is refused, not reported as success (#484)', () => {
const host = { execPath: 'C:/DSH/DSH Desktop Beta.exe', electron: true }
// The shape the Windows ACL provider returns: [process.execPath, runner, …].
const runner = [host.execPath, 'C:/DSH/resources/app/lib/dsh-sandbox-windows-acl/lib/runner.js',
'--workspace', TEST_HOME, '--', 'bash.exe', '-c', 'echo hello']
assert.match(hostRunnerRefusal(runner, {}, host), /^EXECUTOR_UNAVAILABLE: /)
// An ordinary bash argv and a non-Electron host keep spawning their own exec path.
assert.equal(hostRunnerRefusal(['C:/bash/usr/bin/bash.exe', '-c', 'echo hello'], {}, host), undefined)
assert.equal(hostRunnerRefusal(runner, {}, { execPath: host.execPath, electron: false }), undefined)
// A child that already asks Electron to behave as Node runs the runner correctly.
assert.equal(hostRunnerRefusal(runner, { ELECTRON_RUN_AS_NODE: '1' }, host), undefined)
// Windows environment names are case-insensitive, so a lower-cased variant counts too.
assert.equal(hostRunnerRefusal(runner, { electron_run_as_node: '1' }, host), undefined)
})

await test('the session executor refuses the host binary instead of spawning a second GUI instance (#484)', async () => {
const f = fixture({ file: process.execPath })
// This suite runs under plain Node; declare the Desktop runtime for this case only.
Object.defineProperty(process.versions, 'electron', { value: '34.0.0', configurable: true, enumerable: true, writable: true })
try {
const value = await f.definition.execute({ command: 'echo hello', description: 'Run one confined command' }, f.exec)
assert.equal(f.calls.length, 0, 'the host binary must never be spawned')
assert.equal(value.status, 'failed')
assert.match(value.error, /EXECUTOR_UNAVAILABLE: the confined argv starts the host Electron binary/)
} finally { delete process.versions.electron }
// Without the Desktop runtime the same argv is a plain Node invocation and stays spawnable.
const plain = fixture({ file: process.execPath })
const spawned = await plain.definition.execute({ command: 'echo hello', description: 'Run one plain command' }, plain.exec)
assert.equal(plain.calls.length, 1); assert.equal(spawned.error, undefined)
})

report('shells-host')