2026-07-14 20:05:57 +08:00
|
|
|
/**
|
|
|
|
|
* The sandbox POLICY home (`ctx.sandboxPolicy`): the single owner of the
|
2026-07-21 00:44:28 +08:00
|
|
|
* deployment's sandbox fallbacks plus per-session resolution: the file-effect
|
|
|
|
|
* {@link SandboxMode}, the `workspace-write` root, and the override kit (the
|
|
|
|
|
* `sandbox/mode` event, its fold, and its write path, from `./session-mode.ts`).
|
2026-07-14 20:05:57 +08:00
|
|
|
*
|
|
|
|
|
* Both enforcing capability families read the SAME policy here: the sandboxed
|
|
|
|
|
* bash executor (`@deepseek-ai/dsh-bash-sandbox`) and the sandboxed filesystem
|
2026-07-21 00:44:28 +08:00
|
|
|
* provider (`@deepseek-ai/dsh-fs-sandbox`) consume the SAME resolved per-call
|
|
|
|
|
* policy, so bash and fs can never confine to different roots — the split
|
|
|
|
|
* world the sandbox RFC warns about. The service reads session state once at
|
|
|
|
|
* the tool boundary; executors and providers remain session-free.
|
2026-07-14 20:05:57 +08:00
|
|
|
*
|
|
|
|
|
* @module @deepseek-ai/dsh-sandbox-policy
|
|
|
|
|
*/
|
|
|
|
|
|
2026-07-21 00:44:28 +08:00
|
|
|
import { resolve as resolvePath } from 'node:path'
|
2026-07-14 20:05:57 +08:00
|
|
|
import { Context, Service } from 'cordis'
|
|
|
|
|
import z from 'schemastery'
|
2026-07-21 19:53:02 +08:00
|
|
|
import { canonicalPath, type SandboxExecutionPolicy, type SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
2026-07-21 00:44:28 +08:00
|
|
|
import type { Session } from '@deepseek-ai/dsh-session'
|
2026-07-26 22:02:02 +08:00
|
|
|
import { sandboxOverrideOf } from './session-mode.ts'
|
2026-07-14 20:05:57 +08:00
|
|
|
|
2026-07-26 22:02:02 +08:00
|
|
|
export { SANDBOX_MODES, effectiveSandboxMode, sandboxOverrideOf, setSandboxMode } from './session-mode.ts'
|
2026-07-14 20:05:57 +08:00
|
|
|
|
2026-07-21 19:53:02 +08:00
|
|
|
/** Resolve filesystem identity before lexical normalization can erase symlink-sensitive components. */
|
|
|
|
|
function resolveWorkspaceRoot(path: string): string {
|
|
|
|
|
return resolvePath(canonicalPath(path))
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 20:05:57 +08:00
|
|
|
declare module 'cordis' {
|
|
|
|
|
interface Context {
|
|
|
|
|
sandboxPolicy: SandboxPolicyService
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Plugin config: the deployment's sandbox default. All optional — `Config`
|
|
|
|
|
* supplies the defaults (`mode: 'read-only'` is the fail-safe default; a
|
|
|
|
|
* deployment that wants a workspace-writable agent opts in explicitly). The
|
|
|
|
|
* runner choice is NOT here (it is the `ctx.sandbox` provider's config), nor
|
|
|
|
|
* is any per-family knob: this is the one shared policy home.
|
|
|
|
|
*/
|
|
|
|
|
export interface Config {
|
|
|
|
|
/** File-sandbox mode a session starts from (default: `read-only`). */
|
|
|
|
|
mode?: SandboxMode
|
|
|
|
|
/**
|
2026-07-21 00:44:28 +08:00
|
|
|
* Fallback root for agentless calls and sessions without a cwd (default:
|
|
|
|
|
* `process.cwd()`). Normal agent calls use their session cwd instead.
|
2026-07-14 20:05:57 +08:00
|
|
|
*/
|
|
|
|
|
workspaceRoot?: string
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 00:44:28 +08:00
|
|
|
/** Inputs that select the sandbox policy for one capability call. */
|
|
|
|
|
export interface SandboxPolicyRequest {
|
|
|
|
|
/** Calling session; its immutable cwd becomes the workspace boundary. */
|
|
|
|
|
session?: Session
|
|
|
|
|
/** Explicit approved mode override, which outranks session policy. */
|
|
|
|
|
mode?: SandboxMode
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 20:05:57 +08:00
|
|
|
/**
|
|
|
|
|
* The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment
|
2026-07-21 00:44:28 +08:00
|
|
|
* default mode and fallback workspace root. Tool layers call {@link resolve}
|
|
|
|
|
* for each execution so a session's mode log and immutable cwd travel together
|
|
|
|
|
* to every enforcing capability.
|
2026-07-14 20:05:57 +08:00
|
|
|
*/
|
|
|
|
|
export class SandboxPolicyService extends Service {
|
|
|
|
|
// Inline schema call: the config catalog walks `static Config` statically.
|
|
|
|
|
static Config: z<Config> = z.object({
|
|
|
|
|
mode: z.union(['read-only', 'workspace-write', 'danger-full-access'] as const).default('read-only'),
|
|
|
|
|
// No schema default: process.cwd() is resolved in the constructor so the
|
|
|
|
|
// stored root is always absolute regardless of how it was supplied.
|
|
|
|
|
workspaceRoot: z.string(),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
/** The deployment default mode — the fallback beneath a session override. */
|
|
|
|
|
readonly defaultMode: SandboxMode
|
2026-07-21 00:44:28 +08:00
|
|
|
/** The absolute `workspace-write` fallback root for calls without a session cwd. */
|
2026-07-14 20:05:57 +08:00
|
|
|
readonly workspaceRoot: string
|
|
|
|
|
|
|
|
|
|
constructor(ctx: Context, config: Config) {
|
|
|
|
|
super(ctx, 'sandboxPolicy')
|
|
|
|
|
// schemastery (static Config) already filled `mode`; the cast records that
|
|
|
|
|
// runtime fact. `workspaceRoot` has NO schema default, so its fallback to
|
|
|
|
|
// the process cwd is real branching, resolved absolute either way.
|
|
|
|
|
this.defaultMode = config.mode as SandboxMode
|
2026-07-21 19:53:02 +08:00
|
|
|
this.workspaceRoot = resolveWorkspaceRoot(config.workspaceRoot ?? process.cwd())
|
2026-07-21 00:44:28 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Resolve the complete policy for one capability call. An approved explicit
|
2026-07-27 13:45:09 +08:00
|
|
|
* mode outranks the session's override chain ({@link overrideOf}: own
|
|
|
|
|
* post-seed switches, else the inherited header baseline), which outranks
|
|
|
|
|
* the deployment default. A session cwd is its workspace-write boundary;
|
|
|
|
|
* the configured root is the fallback for agentless calls and sessions
|
|
|
|
|
* without a cwd.
|
2026-07-21 00:44:28 +08:00
|
|
|
* @param request - optional session and approved mode override.
|
|
|
|
|
* @returns the fully resolved per-call mode and absolute workspace root.
|
|
|
|
|
*/
|
|
|
|
|
resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy {
|
|
|
|
|
const { session } = request
|
2026-07-26 23:56:41 +08:00
|
|
|
// Resolve the session override FIRST even when an explicit approved mode
|
|
|
|
|
// outranks it: the unconditional durable-header validation must hold on
|
|
|
|
|
// every resolution — a one-shot grant is not a validation bypass.
|
|
|
|
|
const override = session === undefined ? undefined : this.overrideOf(session)
|
2026-07-21 00:44:28 +08:00
|
|
|
return {
|
2026-07-26 23:56:41 +08:00
|
|
|
mode: request.mode ?? override ?? this.defaultMode,
|
2026-07-21 19:53:02 +08:00
|
|
|
workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot),
|
2026-07-21 00:44:28 +08:00
|
|
|
}
|
2026-07-14 20:05:57 +08:00
|
|
|
}
|
2026-07-25 04:06:19 +08:00
|
|
|
|
|
|
|
|
/**
|
2026-07-26 22:32:24 +08:00
|
|
|
* {@link sandboxOverrideOf} surfaced on the service, for consumers that
|
|
|
|
|
* reach policy through `ctx.get('sandboxPolicy')` (the subagent driver's
|
|
|
|
|
* delegation capture, pty-local) rather than a value import.
|
2026-07-26 18:16:45 +08:00
|
|
|
* @param session - the session whose override chain to resolve.
|
|
|
|
|
* @returns the effective override, or `undefined` for a session following
|
|
|
|
|
* the deployment default.
|
2026-07-25 10:51:25 +08:00
|
|
|
*/
|
|
|
|
|
overrideOf(session: Session): SandboxMode | undefined {
|
2026-07-26 22:02:02 +08:00
|
|
|
return sandboxOverrideOf(session)
|
2026-07-25 04:06:19 +08:00
|
|
|
}
|
2026-07-14 20:05:57 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default SandboxPolicyService
|