Fix: the tool substituted the deployment sandbox default for the session's
dsh-bash-sandbox is the composed ctx.shell, and its resolve() stamps `request.sandboxPolicy ?? ctx.sandboxPolicy.resolve()`. Omitting the policy therefore does NOT mean "unconfined" -- it silently substitutes the DEPLOYMENT default (`DSH_PERMISSION_MODE ?? 'workspace-write'`) for the session's preset. On a host with no sandbox runner that confines a danger-full-access session and fails with SandboxUnavailableError before slopo is ever reached, which is exactly what happened: the tool was unusable while bash worked. Resolve the calling session's policy the way dsh-tool-bash does, and pass it on every call. A confining executor with no ctx.sandboxPolicy is now refused outright rather than guessed at. That fix exposes a second problem worth naming. Slopo's state lives under $DSH_HOME, which is outside the workspace root a workspace-write sandbox permits writing to, so under that preset nothing would work. The tool now refuses before running and names both ways out -- run under danger-full-access, or set stateDir to a directory inside the workspace -- instead of letting a write fail halfway through an embed. Five tests pin this: the session's policy is resolved exactly once and travels with the session, a missing policy service is refused, state outside the sandbox is refused before any execution, state inside it proceeds, and status/configure never touch the shell at all.
This commit is contained in:
1 parent
a8e577ae38
commit
e758a723e2
3 files changed
+177
-4
No files matched your search
@@ -26,7 +26,7 @@
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { join, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
@@ -497,6 +497,50 @@ export function apply(ctx, config) {
|
||||
})
|
||||
}
|
||||
|
||||
// Resolve the CALLING SESSION's sandbox policy, the way the bash tool
|
||||
// does. `dsh-bash-sandbox.resolve()` stamps
|
||||
// `request.sandboxPolicy ?? ctx.sandboxPolicy.resolve()`, so omitting it
|
||||
// does not mean "unconfined" — it silently substitutes the DEPLOYMENT
|
||||
// default (`DSH_PERMISSION_MODE ?? 'workspace-write'`) for the session's
|
||||
// preset. That confines a call the session asked to run unconfined, and
|
||||
// on a host with no sandbox runner it fails with SandboxUnavailableError
|
||||
// before slopo is ever reached.
|
||||
const sandboxDefault = shell.sandboxMode
|
||||
const policyService = sandboxDefault === undefined ? undefined : ctx.get('sandboxPolicy')
|
||||
if (sandboxDefault !== undefined && policyService === undefined) {
|
||||
return foreground({
|
||||
ok: false,
|
||||
exitCode: null,
|
||||
errorCode: 'NO_SANDBOX_POLICY',
|
||||
message: 'The mounted shell executor confines commands, but ctx.sandboxPolicy is absent.',
|
||||
remedy: 'Compose @deepseek-ai/dsh-sandbox-policy, or mount an unconfining shell executor.',
|
||||
})
|
||||
}
|
||||
const sandboxPolicy = policyService?.resolve(
|
||||
exec.agent === undefined ? {} : { session: exec.agent.session },
|
||||
)
|
||||
|
||||
// Slopo's state is deliberately outside the workspace, which a
|
||||
// workspace-write sandbox forbids writing to. Say so before a run and
|
||||
// name both ways out, rather than letting a write fail mid-embed.
|
||||
if (
|
||||
sandboxPolicy !== undefined &&
|
||||
sandboxPolicy.mode !== 'danger-full-access' &&
|
||||
!isInside(sandboxPolicy.workspaceRoot, paths.stateDir)
|
||||
) {
|
||||
return foreground({
|
||||
ok: false,
|
||||
exitCode: null,
|
||||
errorCode: 'STATE_OUTSIDE_SANDBOX',
|
||||
message:
|
||||
`This session runs under the "${sandboxPolicy.mode}" sandbox, which permits writes only under ` +
|
||||
`${sandboxPolicy.workspaceRoot} — but slopo's index, config and log live in ${paths.stateDir}.`,
|
||||
remedy:
|
||||
'Either run this session under the danger-full-access preset, or set `stateDir` to a directory ' +
|
||||
'inside the workspace using action "configure".',
|
||||
})
|
||||
}
|
||||
|
||||
/** Run one command through the shell seam. */
|
||||
const run = async (command, { env, timeoutMs, signal } = {}) => {
|
||||
const result = await shell.run(
|
||||
@@ -507,6 +551,8 @@ export function apply(ctx, config) {
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
// Merges over a parent-derived base, so PATH and HOME survive.
|
||||
...(env === undefined ? {} : { env }),
|
||||
// Omitting this substitutes the deployment default; see above.
|
||||
...(sandboxPolicy === undefined ? {} : { sandboxPolicy }),
|
||||
}),
|
||||
)
|
||||
return {
|
||||
@@ -626,3 +672,9 @@ async function readLogTail(path) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** True when `path` is `root` itself or sits underneath it. */
|
||||
function isInside(root, path) {
|
||||
const prefix = root.endsWith(sep) ? root : root + sep
|
||||
return path === root || path.startsWith(prefix)
|
||||
}
|
||||
Reference in new issue
Block a user