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