Merge branch 'codex/invariant-service-seam' into codex/invariant-package-registration-gate
This commit is contained in:
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 */
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user