Merge branch 'codex/invariant-service-seam' into codex/invariant-package-registration-gate
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
# sandbox/ — process-sandbox capability family
|
||||
|
||||
The confinement half of the [capability-seam split](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): an abstract provider interface and platform backends. Consumers hand `ctx.sandbox` the exact argv they are about to spawn and spawn the returned (wrapped) argv instead; policy (`SandboxPolicy`: mode + workspace root) rides each call, so different consumers confine under different policies at the same instant. All **product** packages.
|
||||
The confinement half of the [capability-seam split](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): an abstract provider interface, platform backends, and the shared policy home. Consumers hand `ctx.sandbox` the exact argv they are about to spawn and spawn the returned (wrapped) argv instead; policy (`SandboxPolicy`: mode + workspace root) rides each call, so different consumers confine under different policies at the same instant. All **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `sandbox/` | Abstract process-sandbox seam (the `SandboxProvider` contract + the mode/enforcement/policy vocabulary) | `ctx.sandbox` |
|
||||
| `sandbox/` | Abstract process-sandbox seam (the `SandboxProvider` contract + the mode/enforcement/policy vocabulary) plus the shared ESCALATION kit (`approveEscalation`, the strictly-wider ladder, the denial/hint markers) and the `writableRoots` derivation every enforcement dialect shares | `ctx.sandbox` |
|
||||
| `sandbox-local/` | Local backends by platform chain: Linux `bwrap` else the `landlock-run` launcher (the npm-distributed [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) family, built and released from its own repository), darwin `sandbox-exec`/Seatbelt — multi-candidate chains functionally probed, sole candidates selected directly, verdict cached, fail-closed | (registers `ctx.sandbox`) |
|
||||
| `sandbox-policy/` | The policy home: the deployment default (mode + `workspace-write` boundary root) and the per-session `sandbox/mode` override (event + fold + write path). Both enforcing families read it, so bash and fs can never confine to different roots | `ctx.sandboxPolicy` |
|
||||
|
||||
The seam confines SAME-WORLD subprocesses only (shared filesystem and kernel). Containers, microVMs, and remote executors are NOT backends here — they replace whole capability implementations (`ctx.bash`, `ctx.fs`) as environment-coherent groups; the boundary is recorded in [the sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
|
||||
|
||||
Consumers today: [`bash/bash-sandbox`](../bash/bash-sandbox/) (wraps `['bash', '-c', command]`; see [the acp-agent example's default composition](../../examples/acp-agent/) for the composed leaf). In-process tools (fs/web) cannot be confined by an OS wrapper — their sandbox semantics are policy at their own seams (the sandbox Agent Note's cross-family phase).
|
||||
Consumers today: [`bash/bash-sandbox`](../bash/bash-sandbox/) (wraps `['bash', '-c', command]` through `ctx.sandbox`) and [`fs/fs-sandbox`](../fs/fs-sandbox/) (an in-process path fence, not an argv wrapper — reads `ctx.sandboxPolicy` and enforces the shared mode on write/edit). The cross-family boundary is the sandbox Agent Note's [cross-family fs sandbox](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md) phase; the shared vocabulary lets both families teach the model one denial marker and one escalation flow.
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
* @module @deepseek-ai/dsh-sandbox-local/profiles
|
||||
*/
|
||||
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { grantArgs as landlockGrantArgs } from 'node-addon-landlock-run'
|
||||
import { writableRoots } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
/**
|
||||
@@ -36,31 +35,23 @@ export function landlockProfileArgs(policy: SandboxPolicy): string[] {
|
||||
return landlockGrantArgs({ readOnly: ['/'], readWrite })
|
||||
}
|
||||
|
||||
/** Resolve a granted root to the canonical path the Seatbelt kernel sees. */
|
||||
function canonicalPath(path: string): string {
|
||||
try {
|
||||
return realpathSync(path)
|
||||
} catch {
|
||||
// Missing or unreadable roots stay as spelled; an unresolved root grants
|
||||
// nothing until it exists, which is the conservative outcome.
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
/** Quote one path as an SBPL string literal. */
|
||||
function sbplString(path: string): string {
|
||||
return `"${path.replaceAll('\\', String.raw`\\`).replaceAll('"', String.raw`\"`)}"`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the sandbox-exec arguments and SBPL profile for one policy.
|
||||
* Build the sandbox-exec arguments and SBPL profile for one policy. The
|
||||
* writable roots come from the shared {@link writableRoots} helper (canonical,
|
||||
* deduplicated) so the Seatbelt grant and the in-process fs fence
|
||||
* (`@deepseek-ai/dsh-fs-sandbox`) can never drift apart.
|
||||
* @param policy - file-effect policy to express as an SBPL profile.
|
||||
* @returns sandbox-exec arguments before the trailing separator and command argv.
|
||||
*/
|
||||
export function seatbeltProfileArgs(policy: SandboxPolicy): string[] {
|
||||
const forms = ['(version 1)', '(allow default)', '(deny file-write*)', `(allow file-write* (literal ${sbplString('/dev/null')}))`]
|
||||
if (policy.mode === 'workspace-write') {
|
||||
const roots = [...new Set([policy.workspaceRoot, '/tmp', tmpdir()].map(canonicalPath))]
|
||||
const roots = writableRoots(policy)
|
||||
if (roots.length > 0) {
|
||||
forms.push(`(allow file-write* ${roots.map(root => `(subpath ${sbplString(root)})`).join(' ')})`)
|
||||
}
|
||||
return ['-p', forms.join(' ')]
|
||||
|
||||
36
packages/sandbox/sandbox-policy/README.md
Normal file
36
packages/sandbox/sandbox-policy/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# dsh-sandbox-policy — the sandbox policy home (`ctx.sandboxPolicy`)
|
||||
|
||||
The single owner of the deployment's sandbox policy: the file-effect [`SandboxMode`](../sandbox/README.md) a session starts from, the `workspace-write` boundary root, and the per-session `sandbox/mode` override every enforcing capability family reads.
|
||||
|
||||
## Why a shared home
|
||||
|
||||
Two families enforce the same mode vocabulary: the sandboxed bash executor (`@deepseek-ai/dsh-bash-sandbox`) and the sandboxed filesystem provider (`@deepseek-ai/dsh-fs-sandbox`). If each held its own `mode` + `workspaceRoot` config, the two could drift into a split world — bash confined to one root while fs fences another, exactly what [the sandbox RFC](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) warns against. Both inject `ctx.sandboxPolicy` and read the SAME default instead. The [cross-family fs sandbox RFC](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md) records the decision.
|
||||
|
||||
## Config
|
||||
|
||||
- `mode` — the deployment default `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`), validated at load. Default `read-only` (fail-safe).
|
||||
- `workspaceRoot` — the absolute directory `workspace-write` may write under. Default `process.cwd()`, resolved absolute either way.
|
||||
|
||||
## Surface
|
||||
|
||||
- `ctx.sandboxPolicy.defaultMode` / `ctx.sandboxPolicy.workspaceRoot` — the deployment default the enforcing implementations read for their resolve fallback and boundary.
|
||||
- `effectiveSandboxMode(events)` — the pure fold of a session's `sandbox/mode` events (the last switch wins, or `undefined`). The tool layers apply it to stamp each call, so neither the executor nor the provider depends on session events.
|
||||
- `setSandboxMode(session, mode)` — THE write path for a per-session override: appends exactly one `sandbox/mode` event. The switch IS its event; nothing mutates the mode out of band.
|
||||
- `SANDBOX_MODES` — every mode, for option advertisement and runtime validation.
|
||||
|
||||
## The per-session store
|
||||
|
||||
A runtime switch (an ACP `session/set_config_option`, a test scenario) is one log-only `sandbox/mode` event on the session it applies to. `effective = fold(events) ?? the deployment default`, so an override survives restart by replay, two sessions never see each other's state, and there is no external config store. The event is log-only (the `approval/*` precedent): the model learns the mode from the enforcing tools' denial markers, never from the event. Execution honors the fold in each tool layer, weakest-precedence beneath an escalation grant.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `dsh-tool-bash` and `dsh-tool-fs`, which render the effective mode this service holds in their `[sandbox: …]` denial markers and escalation prompts; the `sandbox/mode` event itself never reaches the model.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumers own any request-prefix changes, and the mode is deliberately absent from the prompt.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`workspaceRoot` is process-wide and fixed for the service's lifetime** — a per-session workspace root is a deferred phase of the sandbox RFC; this package centralizing the root is its groundwork, not its design.
|
||||
- **File-effect modes only** — `SandboxMode` governs file effects; network and process policy are outside its vocabulary, so no knob here restricts them.
|
||||
44
packages/sandbox/sandbox-policy/package.json
Normal file
44
packages/sandbox/sandbox-policy/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-sandbox-policy",
|
||||
"description": "Sandbox policy home (ctx.sandboxPolicy) for the DeepSeek Harness: the deployment default mode + workspace root and the per-session sandbox/mode override, shared by every enforcing capability family",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
84
packages/sandbox/sandbox-policy/src/index.ts
Normal file
84
packages/sandbox/sandbox-policy/src/index.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* The sandbox POLICY home (`ctx.sandboxPolicy`): the single owner of the
|
||||
* deployment's sandbox default — the file-effect {@link SandboxMode} a session
|
||||
* starts from and the `workspace-write` boundary root — plus the per-session
|
||||
* override kit (the `sandbox/mode` event, its fold, and its write path, from
|
||||
* `./session-mode.ts`).
|
||||
*
|
||||
* Both enforcing capability families read the SAME policy here: the sandboxed
|
||||
* bash executor (`@deepseek-ai/dsh-bash-sandbox`) and the sandboxed filesystem
|
||||
* provider (`@deepseek-ai/dsh-fs-sandbox`) inject `ctx.sandboxPolicy` for the
|
||||
* default mode and workspace root, so bash and fs can never confine to
|
||||
* different roots — the split world the sandbox RFC warns about. The default
|
||||
* lives here rather than on either executor's config precisely because it is
|
||||
* one fact two families share.
|
||||
*
|
||||
* This service holds only the DEFAULT; the per-session fold
|
||||
* ({@link effectiveSandboxMode}) is a pure function the tool layers apply to
|
||||
* stamp each call, so neither the executor nor the provider depends on session
|
||||
* events.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-sandbox-policy
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path'
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts'
|
||||
|
||||
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
|
||||
/**
|
||||
* Absolute root directory `workspace-write` may write under (default:
|
||||
* `process.cwd()`). Both enforcing families fence against this SAME root.
|
||||
*/
|
||||
workspaceRoot?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment
|
||||
* default mode and workspace root; enforcing implementations read
|
||||
* {@link defaultMode} and {@link workspaceRoot}, and the tool layers fold each
|
||||
* session's `sandbox/mode` override with {@link effectiveSandboxMode} on top.
|
||||
*/
|
||||
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
|
||||
/** The absolute `workspace-write` boundary root both families fence against. */
|
||||
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
|
||||
this.workspaceRoot = resolve(config.workspaceRoot ?? process.cwd())
|
||||
}
|
||||
}
|
||||
|
||||
export default SandboxPolicyService
|
||||
27
packages/sandbox/sandbox-policy/src/invariant.ts
Normal file
27
packages/sandbox/sandbox-policy/src/invariant.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-sandbox-policy`.
|
||||
* @module @deepseek-ai/dsh-sandbox-policy/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox-policy'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'sandbox-policy-invariant'
|
||||
/** Services required before the companion can register. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** No runtime invariant: the follow-up checks package-owned sandbox-mode events once this topology gate lands. */
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
68
packages/sandbox/sandbox-policy/src/session-mode.ts
Normal file
68
packages/sandbox/sandbox-policy/src/session-mode.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Per-session sandbox-mode override: the session log as the store. A runtime
|
||||
* switch (an ACP `session/set_config_option`, a test scenario) is recorded as
|
||||
* one `sandbox/mode` event on the session it applies to;
|
||||
* `effective = fold(events) ?? the deployment default`, so an override
|
||||
* survives restart by replay, two sessions can never see each other's state,
|
||||
* and there is no external config store. The event is log-only (the
|
||||
* `approval/*` precedent): the model learns the mode from the boundary
|
||||
* markers in the enforcing tools, never from the event itself. EXECUTION
|
||||
* honors the fold in each tool layer — it stamps the effective mode onto the
|
||||
* per-call policy carrier (a bash request's `sandboxMode`, an fs mutation's
|
||||
* `sandboxMode`), weakest-precedence beneath an escalation grant.
|
||||
*
|
||||
* The override is policy state shared by every enforcing family (bash and
|
||||
* filesystem alike), so it lives here in the policy package rather than in any
|
||||
* one capability's seam.
|
||||
*
|
||||
* @module dsh-sandbox-policy/session-mode
|
||||
*/
|
||||
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* The session's sandbox mode was switched — log-only (like `approval/*`;
|
||||
* NOT a surface event, carries no `surfaceOp`): durable and replayable,
|
||||
* never in the model transcript. The LAST such event is the session's
|
||||
* override ({@link effectiveSandboxMode}); who asked for it is derivable
|
||||
* from position (an event after the log's last `request/header*` was a
|
||||
* runtime switch by the user; see the tool layer's narrator).
|
||||
*/
|
||||
'sandbox/mode': { mode: SandboxMode }
|
||||
}
|
||||
}
|
||||
|
||||
/** Every {@link SandboxMode}, for option advertisement and runtime validation of untrusted mode strings. */
|
||||
export const SANDBOX_MODES: readonly SandboxMode[] = ['read-only', 'workspace-write', 'danger-full-access']
|
||||
|
||||
/**
|
||||
* The session's sandbox-mode override: the last `sandbox/mode` event in the
|
||||
* log, or undefined when the session never switched (callers apply the
|
||||
* deployment default). The pure fold — resume needs no catch-up machinery
|
||||
* because replaying the log IS the state.
|
||||
* @param events - session events in log order (other event types are skipped).
|
||||
* @returns the mode of the last switch event, or undefined without one.
|
||||
*/
|
||||
export function effectiveSandboxMode(events: readonly SessionEvent[]): SandboxMode | undefined {
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index] as SessionEvent
|
||||
if (event.type === 'sandbox/mode') return event.data.mode
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* THE write path for a session's sandbox-mode override: appends exactly one
|
||||
* `sandbox/mode` event — the switch IS its event; nothing mutates mode state
|
||||
* out of band. Takes effect on the session's next confined call (bash or fs)
|
||||
* — the consumers fold on every read.
|
||||
* @param session - the session the override belongs to.
|
||||
* @param mode - the mode every subsequent confined call in this session runs
|
||||
* under (until the next switch).
|
||||
*/
|
||||
export function setSandboxMode(session: Session, mode: SandboxMode): void {
|
||||
session.append('sandbox/mode', { mode })
|
||||
}
|
||||
67
packages/sandbox/sandbox-policy/tests/policy.spec.ts
Normal file
67
packages/sandbox/sandbox-policy/tests/policy.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Tests for the sandbox-policy home: the deployment default (mode +
|
||||
* workspaceRoot) the service exposes, and the per-session `sandbox/mode`
|
||||
* override kit (fold + write path) both enforcing families read.
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SandboxPolicyService, { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
|
||||
async function mounted(config: { mode?: 'read-only' | 'workspace-write' | 'danger-full-access'; workspaceRoot?: string } = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SandboxPolicyService, config)
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('SandboxPolicyService', () => {
|
||||
it('defaults to read-only under the process cwd', async () => {
|
||||
const ctx = await mounted()
|
||||
expect(ctx.sandboxPolicy.defaultMode).toBe('read-only')
|
||||
expect(ctx.sandboxPolicy.workspaceRoot).toBe(resolve(process.cwd()))
|
||||
})
|
||||
|
||||
it('carries a configured mode and resolves the workspace root absolute', async () => {
|
||||
const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/ws/../ws/./sub' })
|
||||
expect(ctx.sandboxPolicy.defaultMode).toBe('workspace-write')
|
||||
expect(ctx.sandboxPolicy.workspaceRoot).toBe(resolve('/ws/../ws/./sub'))
|
||||
})
|
||||
|
||||
it('rejects a mode outside the closed vocabulary at load', async () => {
|
||||
const ctx = new Context()
|
||||
// schemastery rejects the union violation when the plugin loads.
|
||||
await expect(ctx.plugin(SandboxPolicyService, { mode: 'yolo' as never })).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('unregisters cleanly from a child fiber (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(SandboxPolicyService, {})
|
||||
expect(ctx.sandboxPolicy).toBeDefined()
|
||||
await fiber.dispose()
|
||||
expect(ctx.get('sandboxPolicy')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the sandbox/mode session kit', () => {
|
||||
it('SANDBOX_MODES lists every mode for advertisement and validation', () => {
|
||||
expect(SANDBOX_MODES).toEqual(['read-only', 'workspace-write', 'danger-full-access'])
|
||||
})
|
||||
|
||||
it('effectiveSandboxMode folds to the last switch, or undefined without one', () => {
|
||||
const session = new Session(SessionId('sess-fold'))
|
||||
expect(effectiveSandboxMode(session.events)).toBeUndefined()
|
||||
setSandboxMode(session, 'workspace-write')
|
||||
setSandboxMode(session, 'read-only')
|
||||
expect(effectiveSandboxMode(session.events)).toBe('read-only')
|
||||
})
|
||||
|
||||
it('setSandboxMode appends exactly one sandbox/mode event per switch', () => {
|
||||
const session = new Session(SessionId('sess-write'))
|
||||
setSandboxMode(session, 'danger-full-access')
|
||||
const modeEvents = session.events.filter(e => e.type === 'sandbox/mode')
|
||||
expect(modeEvents).toHaveLength(1)
|
||||
expect(modeEvents[0]?.data).toEqual({ mode: 'danger-full-access' })
|
||||
})
|
||||
})
|
||||
30
packages/sandbox/sandbox-policy/tsconfig.json
Normal file
30
packages/sandbox/sandbox-policy/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
189
packages/sandbox/sandbox/src/escalation.ts
Normal file
189
packages/sandbox/sandbox/src/escalation.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* The escalation vocabulary and choreography shared by every sandbox-enforcing
|
||||
* tool family (`@deepseek-ai/dsh-tool-bash`, `@deepseek-ai/dsh-tool-fs`): the
|
||||
* strictly-wider ladder, the argument-pairing validation, the model-facing
|
||||
* denial/hint markers, and {@link approveEscalation} — the ordered fail-closed
|
||||
* sequence that resolves a `sandbox_permissions` request through a
|
||||
* user-approval channel BEFORE anything executes. One home keeps the two
|
||||
* families' approval ordering and verbatim error texts from drifting apart.
|
||||
*
|
||||
* The channel is a minimal STRUCTURAL function shape ({@link EscalationAsk}),
|
||||
* not the approval service type: the tool layer — which owns the agent, the
|
||||
* call id, and the tool name — closes over `ctx.approval.request(...)` and
|
||||
* hands the closure down, so this package never depends on the approval or
|
||||
* agent packages.
|
||||
*
|
||||
* @module dsh-sandbox/escalation
|
||||
*/
|
||||
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type { SandboxMode } from './index.ts'
|
||||
|
||||
/**
|
||||
* The strictly-wider table: what a call whose effective mode is the key may
|
||||
* escalate TO. Checked at EXECUTION, never baked into a tool schema — the
|
||||
* schema's enum is {@link ESCALATION_TARGETS}, because schemas are
|
||||
* registry-global while the effective mode is per-call truth.
|
||||
*/
|
||||
export const WIDER_MODES: Record<string, readonly SandboxMode[]> = {
|
||||
'read-only': ['workspace-write', 'danger-full-access'],
|
||||
'workspace-write': ['danger-full-access'],
|
||||
}
|
||||
|
||||
/**
|
||||
* The closed escalation-target vocabulary — every mode a call could ever
|
||||
* escalate TO (`read-only` is the floor; nothing escalates to it). Advertised
|
||||
* whenever the mounted capability confines: cutting the enum down to the modes
|
||||
* wider than the composition's DEFAULT would strand a session whose effective
|
||||
* mode sits below it (a `danger-full-access` default would advertise nothing
|
||||
* while a narrower-switched session stays confined with no lever).
|
||||
*/
|
||||
export const ESCALATION_TARGETS: readonly SandboxMode[] = ['workspace-write', 'danger-full-access']
|
||||
|
||||
/**
|
||||
* Validate the escalation argument pairing a tool schema cannot express:
|
||||
* `sandbox_permissions` and `justification` travel together — an approval
|
||||
* prompt without a reason, or a reason driving nothing, is a malformed ask —
|
||||
* and the justification must be a non-empty sentence.
|
||||
* @param sandboxPermissions - the raw `sandbox_permissions` argument, if given.
|
||||
* @param justification - the raw `justification` argument, if given.
|
||||
*/
|
||||
export function validateEscalationArgs(sandboxPermissions: string | undefined, justification: string | undefined): void {
|
||||
if (sandboxPermissions !== undefined && justification === undefined) {
|
||||
throw new Error('invalid escalation: sandbox_permissions requires a justification')
|
||||
}
|
||||
if (justification !== undefined && sandboxPermissions === undefined) {
|
||||
throw new Error('invalid escalation: justification is only valid together with sandbox_permissions')
|
||||
}
|
||||
if (justification !== undefined && justification.trim().length === 0) {
|
||||
throw new Error('invalid justification: expected a non-empty sentence')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The model-facing denial marker — the one vocabulary both enforcing families
|
||||
* teach and report, so the model recognizes a policy denial identically
|
||||
* whether the kernel refused a bash file effect or the filesystem provider's
|
||||
* fence refused a mutation.
|
||||
* @param mode - the mode the denied call ran under.
|
||||
* @returns the marker line, exactly as the model sees it.
|
||||
*/
|
||||
export function sandboxDenialMarker(mode: SandboxMode): string {
|
||||
return `[sandbox: file access denied under ${mode} mode]`
|
||||
}
|
||||
|
||||
/**
|
||||
* The same-turn escalation hint that rides a denial when the composition
|
||||
* advertises the escalation fields — the nudge lives at the decision point so
|
||||
* the sanctioned retry does not depend on the model recalling the tool
|
||||
* description.
|
||||
* @param subject - the family's noun for the denied action (`command` for
|
||||
* bash, `operation` for a filesystem mutation).
|
||||
* @returns the hint line, exactly as the model sees it.
|
||||
*/
|
||||
export function escalationHintMarker(subject: string): string {
|
||||
return `[sandbox: escalation available — retry this exact ${subject} once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]`
|
||||
}
|
||||
|
||||
/**
|
||||
* The closed outcome vocabulary of one escalation ask — structurally identical
|
||||
* to the approval seam's `ApprovalOutcome` so an `ApprovalService.request`
|
||||
* return is assignable without this package importing it.
|
||||
*/
|
||||
export type EscalationOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
|
||||
|
||||
/**
|
||||
* The minimal approval-request shape {@link approveEscalation} needs —
|
||||
* structurally the approval seam's `ApprovalService`, generic over the agent
|
||||
* type `A` and call-id type `C` so this package resolves escalations through
|
||||
* `ctx.approval` without importing the approval or agent packages (the tool
|
||||
* layer infers `A`/`C` as its own `Agent`/`CallId`).
|
||||
*/
|
||||
export interface EscalationApprover<A = object, C = string> {
|
||||
/**
|
||||
* Ask the human to approve one action, resolving to a closed outcome.
|
||||
* @param req - the audit-self-contained request (agent, tool, call id, reason, optional signal).
|
||||
* @returns the human's decision as a closed {@link EscalationOutcome}.
|
||||
*/
|
||||
request(req: { agent: A; toolName: string; callId: C; reason: string; signal?: AbortSignal }): Promise<EscalationOutcome>
|
||||
}
|
||||
|
||||
/**
|
||||
* The approval ingredients an escalating tool hands {@link approveEscalation}:
|
||||
* the approval requester (`ctx.approval`, or `undefined` when none is
|
||||
* composed), the calling agent (or `undefined` for an agent-less execution),
|
||||
* and the call's identity. The tool layer holds all of these; this package
|
||||
* only judges them.
|
||||
*/
|
||||
export interface EscalationApproval<A = object, C = string> {
|
||||
/** The approval requester (`ctx.approval`), or `undefined` when none is composed. */
|
||||
approver: EscalationApprover<A, C> | undefined
|
||||
/** The calling agent, or `undefined` for an agent-less execution (fails closed). */
|
||||
agent: A | undefined
|
||||
/** The tool-call id the approval prompt attaches to. */
|
||||
callId: C
|
||||
/** The tool name recorded on the approval request. */
|
||||
toolName: string
|
||||
/** The tool-execution abort signal the approval request rides, when present. */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** One escalation request, as {@link approveEscalation} judges it. */
|
||||
export interface EscalationRequest {
|
||||
/** The requested target mode (schema-pinned to {@link ESCALATION_TARGETS} when advertised). */
|
||||
requestedMode: string
|
||||
/** The model's one-sentence reason, shown verbatim to the user inside the audit reason. */
|
||||
justification: string
|
||||
/** The call's effective mode (session override ?? composition default) the request must strictly widen. */
|
||||
effectiveMode: SandboxMode
|
||||
/** The family's noun for the escalated action in user-facing texts (`command` for bash, `operation` for fs). */
|
||||
subject: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a sandbox-escalation request BEFORE anything executes: check strict
|
||||
* widening against the call's effective mode, then resolve the approval
|
||||
* channel, then map every outcome — the ordered fail-closed sequence both
|
||||
* enforcing families share. Returns the granted mode to stamp onto exactly
|
||||
* this call; throws the distinct verbatim text for every other path (a
|
||||
* non-widening request, a missing approval service, an agent-less execution,
|
||||
* a rejection, a cancellation, an unanswerable ask) — the tool registry turns
|
||||
* the throw into the call's isError result, and nothing has run. A
|
||||
* non-widening request never prompts a human.
|
||||
* @param request - the escalation to judge (see {@link EscalationRequest}).
|
||||
* @param approval - the approval ingredients the tool holds (see {@link EscalationApproval}).
|
||||
* @returns the granted mode, consumed by the one call that asked.
|
||||
*/
|
||||
export async function approveEscalation<A, C>(request: EscalationRequest, approval: EscalationApproval<A, C>): Promise<SandboxMode> {
|
||||
const { requestedMode: mode, effectiveMode, justification, subject } = request
|
||||
// Strict widening is an EXECUTION check against the call's effective mode —
|
||||
// deliberately not a schema constraint (the enum is the closed target
|
||||
// vocabulary; the effective mode is per-call truth).
|
||||
if (!(WIDER_MODES[effectiveMode] ?? []).includes(mode as SandboxMode)) {
|
||||
throw new Error(`sandbox escalation to "${mode}" is not strictly wider than this call's current "${effectiveMode}" mode`)
|
||||
}
|
||||
if (approval.approver === undefined) {
|
||||
throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval service is composed`)
|
||||
}
|
||||
if (approval.agent === undefined) {
|
||||
throw new Error(`sandbox escalation to "${mode}" requires approval, but the call has no agent to route it through`)
|
||||
}
|
||||
// Self-contained for the audit trail: approval/asked stores this reason,
|
||||
// and the target mode is part of the grant's identity.
|
||||
const outcome = await approval.approver.request({
|
||||
agent: approval.agent,
|
||||
toolName: approval.toolName,
|
||||
callId: approval.callId,
|
||||
reason: `escalate sandbox to ${mode}: ${justification}`,
|
||||
...approval.signal ? { signal: approval.signal } : {},
|
||||
})
|
||||
switch (outcome) {
|
||||
// The schema enum already pinned `mode` to the closed target vocabulary;
|
||||
// the check above proved it is strictly wider.
|
||||
case 'allowed-once': return mode as SandboxMode
|
||||
case 'rejected': throw new Error(`the user rejected escalating this ${subject} to "${mode}"`)
|
||||
case 'cancelled': throw new Error(`approval for escalating to "${mode}" was cancelled`)
|
||||
case 'unavailable': throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval channel is available`)
|
||||
default: return assertNever(outcome, 'EscalationOutcome')
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,17 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
export {
|
||||
ESCALATION_TARGETS,
|
||||
WIDER_MODES,
|
||||
approveEscalation,
|
||||
escalationHintMarker,
|
||||
sandboxDenialMarker,
|
||||
validateEscalationArgs,
|
||||
} from './escalation.ts'
|
||||
export type { EscalationApproval, EscalationApprover, EscalationOutcome, EscalationRequest } from './escalation.ts'
|
||||
export { canonicalPath, writableRoots } from './roots.ts'
|
||||
|
||||
/**
|
||||
* File-effect policy for confined processes. `read-only` permits only required
|
||||
* sinks such as `/dev/null`; `workspace-write` also permits the workspace and a
|
||||
|
||||
51
packages/sandbox/sandbox/src/roots.ts
Normal file
51
packages/sandbox/sandbox/src/roots.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* The writable-root derivation shared by every enforcement dialect that
|
||||
* expresses a mode as a canonical allow-list: `workspace-write` means "the
|
||||
* workspace root plus the platform temp areas", and this module is that
|
||||
* meaning's one home. The Seatbelt profile
|
||||
* (`@deepseek-ai/dsh-sandbox-local`) and the in-process filesystem fence
|
||||
* (`@deepseek-ai/dsh-fs-sandbox`) both derive their allow-list here, so "the
|
||||
* write tool cannot write /tmp but bash can" asymmetries cannot arise between
|
||||
* them. The bwrap and Landlock dialects keep their own grant spellings (an
|
||||
* ephemeral `/tmp` mount, launcher-owned flags) — the honest per-runner
|
||||
* differences recorded in the sandbox RFC — with parity pinned by test.
|
||||
*
|
||||
* @module dsh-sandbox/roots
|
||||
*/
|
||||
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type { SandboxPolicy } from './index.ts'
|
||||
|
||||
/**
|
||||
* Resolve a granted root to the path the enforcement layer actually compares:
|
||||
* canonical (symlinks resolved), because both Seatbelt filters and the fs
|
||||
* fence's containment check match resolved paths — `/tmp` IS `/private/tmp`
|
||||
* on darwin, and an as-spelled grant would match nothing.
|
||||
* @param path - the root as configured or platform-reported.
|
||||
* @returns the canonical path, or the spelling as-is when resolution fails
|
||||
* (a missing root matches nothing until it exists — the conservative
|
||||
* outcome; inventing a fallback would grant a path the caller never named).
|
||||
*/
|
||||
export function canonicalPath(path: string): string {
|
||||
try {
|
||||
return realpathSync(path)
|
||||
} catch {
|
||||
// realpathSync failed: the path (or a prefix) is missing or unreadable.
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The roots one confined execution may WRITE under — the mode's meaning as a
|
||||
* canonical, deduplicated allow-list. `read-only` allows nothing;
|
||||
* `workspace-write` allows the policy's workspace root, the host `/tmp`, and
|
||||
* the per-user platform temp dir (`os.tmpdir()` — the real temp area for
|
||||
* mkstemp-family tools; omitting it would deny what the mode promises).
|
||||
* @param policy - the file-effect policy to derive the allow-list from.
|
||||
* @returns the canonical writable roots; empty exactly under `read-only`.
|
||||
*/
|
||||
export function writableRoots(policy: SandboxPolicy): string[] {
|
||||
if (policy.mode !== 'workspace-write') return []
|
||||
return [...new Set([policy.workspaceRoot, '/tmp', tmpdir()].map(canonicalPath))]
|
||||
}
|
||||
111
packages/sandbox/sandbox/tests/escalation.spec.ts
Normal file
111
packages/sandbox/sandbox/tests/escalation.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Tests for the shared escalation vocabulary and choreography: the strictly-
|
||||
* wider ladder, the argument-pairing validation, the model-facing markers, and
|
||||
* {@link approveEscalation}'s ordered fail-closed sequence. Both enforcing tool
|
||||
* families (`dsh-tool-bash`, `dsh-tool-fs`) delegate here, so the ordering and
|
||||
* verbatim texts are pinned once, next to the vocabulary that owns them.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ESCALATION_TARGETS,
|
||||
WIDER_MODES,
|
||||
approveEscalation,
|
||||
escalationHintMarker,
|
||||
sandboxDenialMarker,
|
||||
validateEscalationArgs,
|
||||
} from '@deepseek-ai/dsh-sandbox'
|
||||
import type { EscalationApprover, EscalationOutcome } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
describe('the strictly-wider ladder', () => {
|
||||
it('read-only escalates to either wider mode; workspace-write only to full access', () => {
|
||||
expect(WIDER_MODES['read-only']).toEqual(['workspace-write', 'danger-full-access'])
|
||||
expect(WIDER_MODES['workspace-write']).toEqual(['danger-full-access'])
|
||||
expect(WIDER_MODES['danger-full-access']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('the target enum is the closed set every session could escalate TO (read-only is the floor)', () => {
|
||||
expect(ESCALATION_TARGETS).toEqual(['workspace-write', 'danger-full-access'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEscalationArgs', () => {
|
||||
it('accepts neither field, or both with a non-empty justification', () => {
|
||||
expect(() => { validateEscalationArgs(undefined, undefined) }).not.toThrow()
|
||||
expect(() => { validateEscalationArgs('workspace-write', 'because the workspace needs it') }).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects one field without the other, and a blank justification', () => {
|
||||
expect(() => { validateEscalationArgs('workspace-write', undefined) }).toThrow(/requires a justification/)
|
||||
expect(() => { validateEscalationArgs(undefined, 'orphan reason') }).toThrow(/only valid together with sandbox_permissions/)
|
||||
expect(() => { validateEscalationArgs('workspace-write', ' ') }).toThrow(/non-empty sentence/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the model-facing markers', () => {
|
||||
it('the denial marker names the mode', () => {
|
||||
expect(sandboxDenialMarker('read-only')).toBe('[sandbox: file access denied under read-only mode]')
|
||||
expect(sandboxDenialMarker('workspace-write')).toBe('[sandbox: file access denied under workspace-write mode]')
|
||||
})
|
||||
|
||||
it('the hint marker names the family subject', () => {
|
||||
expect(escalationHintMarker('command')).toContain('retry this exact command once with sandbox_permissions')
|
||||
expect(escalationHintMarker('operation')).toContain('retry this exact operation once with sandbox_permissions')
|
||||
})
|
||||
})
|
||||
|
||||
describe('approveEscalation', () => {
|
||||
const req = (over: Partial<Parameters<typeof approveEscalation>[0]> = {}) => ({
|
||||
requestedMode: 'workspace-write',
|
||||
justification: 'the user asked to write in the workspace',
|
||||
effectiveMode: 'read-only' as const,
|
||||
subject: 'command',
|
||||
...over,
|
||||
})
|
||||
/** An approver that records the request and returns a fixed outcome. */
|
||||
const approver = (outcome: EscalationOutcome, sink?: (req: unknown) => void): EscalationApprover => ({
|
||||
request: async (request) => { sink?.(request); return outcome },
|
||||
})
|
||||
const ingredients = (over: Partial<Parameters<typeof approveEscalation>[1]> = {}) => ({
|
||||
approver: approver('allowed-once'),
|
||||
agent: {},
|
||||
callId: 'call-1',
|
||||
toolName: 'bash',
|
||||
...over,
|
||||
})
|
||||
|
||||
it('grants: returns the requested mode, asking through the approver with the audit reason', async () => {
|
||||
const seen: { reason?: string }[] = []
|
||||
const granted = await approveEscalation(req(), ingredients({ approver: approver('allowed-once', r => seen.push(r as { reason?: string })) }))
|
||||
expect(granted).toBe('workspace-write')
|
||||
expect(seen[0]?.reason).toBe('escalate sandbox to workspace-write: the user asked to write in the workspace')
|
||||
})
|
||||
|
||||
it('a non-widening request fails closed with its own text and never asks', async () => {
|
||||
const seen: unknown[] = []
|
||||
const spy = ingredients({ approver: approver('allowed-once', r => seen.push(r)) })
|
||||
await expect(approveEscalation(req({ requestedMode: 'read-only' }), spy))
|
||||
.rejects.toThrow(/not strictly wider than this call's current "read-only" mode/)
|
||||
await expect(approveEscalation(req({ requestedMode: 'workspace-write', effectiveMode: 'danger-full-access' as never }), spy))
|
||||
.rejects.toThrow(/not strictly wider/)
|
||||
expect(seen).toEqual([])
|
||||
})
|
||||
|
||||
it('a missing approval service and an agent-less call each fail closed with distinct text', async () => {
|
||||
await expect(approveEscalation(req(), ingredients({ approver: undefined }))).rejects.toThrow(/no approval service is composed/)
|
||||
await expect(approveEscalation(req(), ingredients({ agent: undefined }))).rejects.toThrow(/no agent to route it through/)
|
||||
})
|
||||
|
||||
it('maps each non-grant outcome to its distinct verbatim text (subject in the rejection)', async () => {
|
||||
await expect(approveEscalation(req({ subject: 'operation' }), ingredients({ approver: approver('rejected') })))
|
||||
.rejects.toThrow('the user rejected escalating this operation to "workspace-write"')
|
||||
await expect(approveEscalation(req(), ingredients({ approver: approver('cancelled') })))
|
||||
.rejects.toThrow('approval for escalating to "workspace-write" was cancelled')
|
||||
await expect(approveEscalation(req(), ingredients({ approver: approver('unavailable') })))
|
||||
.rejects.toThrow('no approval channel is available')
|
||||
})
|
||||
|
||||
it('an outcome outside the closed union trips the exhaustiveness guard (defensive)', async () => {
|
||||
await expect(approveEscalation(req(), ingredients({ approver: approver('bogus' as never) }))).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
39
packages/sandbox/sandbox/tests/roots.spec.ts
Normal file
39
packages/sandbox/sandbox/tests/roots.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Tests for the writable-root derivation: the mode's meaning as a canonical
|
||||
* allow-list. Pinned here so the fs fence and the Seatbelt profile — both
|
||||
* deriving from `writableRoots` — cannot drift.
|
||||
*/
|
||||
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
describe('canonicalPath', () => {
|
||||
it('resolves symlinks (an existing path realpaths)', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-roots-'))
|
||||
expect(canonicalPath(dir)).toBe(realpathSync(dir))
|
||||
})
|
||||
|
||||
it('returns the spelling as-is when the path cannot be resolved (conservative — matches nothing until it exists)', () => {
|
||||
expect(canonicalPath('/does/not/exist/anywhere-xyz')).toBe('/does/not/exist/anywhere-xyz')
|
||||
})
|
||||
})
|
||||
|
||||
describe('writableRoots', () => {
|
||||
it('read-only grants nothing', () => {
|
||||
expect(writableRoots({ mode: 'read-only', workspaceRoot: process.cwd() })).toEqual([])
|
||||
})
|
||||
|
||||
it('workspace-write grants the workspace root plus the platform temp areas, canonical and deduplicated', () => {
|
||||
const ws = mkdtempSync(join(tmpdir(), 'dsh-ws-'))
|
||||
const roots = writableRoots({ mode: 'workspace-write', workspaceRoot: ws })
|
||||
expect(roots).toContain(realpathSync(ws))
|
||||
expect(roots).toContain(canonicalPath('/tmp'))
|
||||
expect(roots).toContain(realpathSync(tmpdir()))
|
||||
// Deduplicated after canonicalization (/tmp and os.tmpdir() may coincide).
|
||||
expect(new Set(roots).size).toBe(roots.length)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user