feat(sandbox): deny confined executions read access to the credential document

The credential store is 0600 under a 0700 directory, which stops other OS
users but not the model: tool processes run as the same user, so under
the shipped danger-full-access default they read it like any other file.

SandboxExecutionPolicy grows readDenyPaths, and sandbox-policy defaults
it to $DSH_HOME/.env — the exact file rather than the harness home, so
the model keeps its documented access to its own session log. Seatbelt
appends a trailing deny (last matching rule wins) and bwrap maps
/dev/null over each path after any workspace bind; Landlock grants are a
pure allow-list that cannot subtract from its own / read grant, so
confine() reports partial enforcement there instead of claiming a
boundary the process does not have.

A real-kernel Seatbelt e2e proves the shape: the same read succeeds
unconfined and fails under the denial, while a sibling file in the same
directory stays readable. Both READMEs state the residual boundary
plainly — no confining mode means no boundary — and record the OS
keychain provider as the real answer.
This commit is contained in:
Yichen Jiang
2026-07-30 16:02:13 +08:00
parent d91f0227e6
commit 7606a99813
11 changed files with 167 additions and 7 deletions

View File

@@ -228,7 +228,12 @@ export class LocalSandboxProvider extends SandboxProvider {
const selected = this.selectRunner(policy.mode)
return {
argv: [...this.runnerArgv(selected.runner, policy), '--', ...argv],
enforcement: selected.enforcement,
// Landlock grants are a pure allow-list, so it cannot subtract a read
// denial from its own `/` read grant: promising `full` there would
// misreport a boundary the process does not have.
enforcement: selected.runner === 'landlock' && (policy.readDenyPaths?.length ?? 0) > 0
? 'partial'
: selected.enforcement,
denialSignatures: DENIAL_SIGNATURES[selected.runner],
runnerFailureSignatures: RUNNER_FAILURE_SIGNATURES[selected.runner],
}

View File

@@ -5,9 +5,14 @@
*/
import { grantArgs as landlockGrantArgs } from 'node-addon-landlock-run'
import { writableRoots } from '@deepseek-ai/dsh-sandbox'
import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
/** This policy's read denials, canonical and deduplicated like the writable roots. */
function denyPaths(policy: SandboxPolicy): string[] {
return [...new Set((policy.readDenyPaths ?? []).map(path => canonicalPath(path)))]
}
/**
* Build the bwrap profile arguments for one file-effect policy.
* @param policy - file-effect policy to express as bwrap mounts.
@@ -19,6 +24,10 @@ export function bwrapProfileArgs(policy: SandboxPolicy): string[] {
args.push('--tmpfs', '/tmp')
args.push('--bind', policy.workspaceRoot, policy.workspaceRoot)
}
// Read denials come last so a workspace bind can never re-expose one.
// `/dev/null` over the path reads as empty; the `-try` form tolerates a
// path that does not exist yet (no credential stored so far).
for (const path of denyPaths(policy)) args.push('--ro-bind-try', '/dev/null', path)
return args
}
@@ -28,6 +37,10 @@ export function bwrapProfileArgs(policy: SandboxPolicy): string[] {
* @returns launcher grant arguments before the trailing separator and command argv.
*/
export function landlockProfileArgs(policy: SandboxPolicy): string[] {
// Landlock grants are a pure allow-list: a read grant on `/` cannot be
// subtracted from, so a requested read denial is unenforceable here. The
// provider reports `partial` enforcement for exactly this case rather than
// pretending the boundary exists.
const readWrite = ['/dev/null']
if (policy.mode === 'workspace-write') {
readWrite.push('/tmp', policy.workspaceRoot)
@@ -54,5 +67,13 @@ export function seatbeltProfileArgs(policy: SandboxPolicy): string[] {
if (roots.length > 0) {
forms.push(`(allow file-write* ${roots.map(root => `(subpath ${sbplString(root)})`).join(' ')})`)
}
// SBPL applies the last matching rule, so the read denial is appended after
// every allow above and governs both reads and writes of those paths. Both
// filters are emitted so a denial may name a file or a directory.
const denied = denyPaths(policy)
if (denied.length > 0) {
const filters = denied.map(path => `(literal ${sbplString(path)}) (subpath ${sbplString(path)})`).join(' ')
forms.push(`(deny file-read* file-write* ${filters})`)
}
return ['-p', forms.join(' ')]
}

View File

@@ -62,6 +62,27 @@ describe('profile dialects', () => {
])
})
it('bwrap read denial: /dev/null over each denied path, after any workspace bind', () => {
expect(bwrapProfileArgs({ ...WW, readDenyPaths: ['/ws/secret.env'] })).toEqual([
'--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent',
'--tmpfs', '/tmp', '--bind', '/ws', '/ws',
// The workspace bind above would otherwise re-expose the file.
'--ro-bind-try', '/dev/null', '/ws/secret.env',
])
})
it('landlock ignores read denials: a `/` read grant cannot subtract from itself', () => {
expect(landlockProfileArgs({ ...RO, readDenyPaths: ['/ws/secret.env'] }))
.toEqual(landlockProfileArgs(RO))
})
it('seatbelt read denial: a trailing deny naming the path as both a file and a directory', () => {
expect(seatbeltProfileArgs({ ...RO, readDenyPaths: ['/ws/secret.env'] })).toEqual([
'-p',
`${SEATBELT_RO_PROFILE} (deny file-read* file-write* (literal "/ws/secret.env") (subpath "/ws/secret.env"))`,
])
})
it('landlock read-only: readable tree plus a writable /dev/null, nothing else', () => {
// /dev/null specifically, NOT /dev: a whole-/dev grant would let confined
// commands write real host paths beneath it (/dev/shm) under read-only.

View File

@@ -1,6 +1,6 @@
import { spawnSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
@@ -70,6 +70,37 @@ describe.skipIf(!seatbeltUsable)('sandbox-local: real Seatbelt confinement throu
expect(result.stdout).toBe('dev-ok\n')
})
it('denies reading a credential document the mode would otherwise allow', async () => {
// The harness's own secret store: readable to the user, and the model's
// bash runs as that user — only the confinement can take it away.
const workdir = await tempDir(tmpdir())
const secret = join(workdir, '.env')
await writeFile(secret, 'DEEPSEEK_API_KEY=sk-must-not-leak\n', { mode: 0o600 })
const sandbox = await provider()
const allowed = runConfined(sandbox, `cat ${secret}`, { mode: 'read-only', workspaceRoot: workdir })
expect(allowed.result.stdout).toContain('sk-must-not-leak')
const denied = runConfined(sandbox, `cat ${secret}`, {
mode: 'read-only',
workspaceRoot: workdir,
readDenyPaths: [secret],
})
expect(denied.result.stdout).not.toContain('sk-must-not-leak')
expect(denied.result.status).not.toBe(0)
expect(denied.confined.enforcement).toBe('full')
// Everything else under the same directory stays readable: the denial is
// the credential document, not the harness home.
const sibling = join(workdir, 'notes.txt')
await writeFile(sibling, 'ordinary\n')
const neighbour = runConfined(sandbox, `cat ${sibling}`, {
mode: 'read-only',
workspaceRoot: workdir,
readDenyPaths: [secret],
})
expect(neighbour.result.stdout).toBe('ordinary\n')
})
it('read-only grants no temp area: a write under the user temp dir is denied too', async () => {
// The per-user darwin temp dir is a workspace-write grant, not a
// read-only one — under read-only the only write-shaped path is /dev/null.

View File

@@ -13,6 +13,12 @@ Two families enforce the same mode vocabulary: the sandboxed bash executor (`@de
- `mode` — the deployment default `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`), validated at load. Default `read-only` (fail-safe).
- `workspaceRoot` — the fallback directory `workspace-write` may write under for agentless calls or sessions without a cwd. Default `process.cwd()`, resolved to its absolute filesystem identity either way. A normal agent call uses its session header's immutable `cwd` instead.
## Read denials
`readDenyPaths` names absolute paths a **confined** execution must not read, whatever its mode otherwise permits. Omitted (or empty) denies the harness credential document `$DSH_HOME/.env`; a non-empty list replaces that default. Denials name exact paths rather than roots on purpose: denying the whole harness home would also take away the model's documented access to its own session log.
Enforcement is backend-shaped. Seatbelt appends a trailing `deny file-read* file-write*` (last matching rule wins) and bwrap maps `/dev/null` over each path after any workspace bind; Landlock grants are a pure allow-list, so a read grant on `/` cannot be subtracted from and `confine()` reports `partial` enforcement rather than pretending the boundary exists. `danger-full-access` confines nothing at all, so no denial applies there — the credential document is then protected only by its file mode, which does not stop a same-UID tool process.
## Surface
- `ctx.sandboxPolicy.resolve({ session?, mode? })` — resolves one complete per-call policy. An explicit approved mode outranks the session's last `sandbox/mode` event, which outranks `defaultMode`; the session's immutable `cwd` is canonicalized with filesystem semantics before becoming `workspaceRoot`, otherwise the configured fallback applies. Canonicalization precedes lexical normalization so `symlink/..` agrees with process working-directory resolution.

View File

@@ -28,6 +28,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
@@ -37,6 +38,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"

View File

@@ -14,10 +14,11 @@
* @module @deepseek-ai/dsh-sandbox-policy
*/
import { resolve as resolvePath } from 'node:path'
import { join, resolve as resolvePath } from 'node:path'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { canonicalPath, type SandboxExecutionPolicy, type SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import type { Session } from '@deepseek-ai/dsh-session'
import { effectiveSandboxMode } from './session-mode.ts'
@@ -49,6 +50,16 @@ export interface Config {
* `process.cwd()`). Normal agent calls use their session cwd instead.
*/
workspaceRoot?: string
/**
* Absolute paths confined executions must not read, whatever their mode
* otherwise permits. Omitted (or empty) denies the harness home's
* credential document (`$DSH_HOME/.env`) — exactly that file, so the model
* keeps the documented access to its own session log under the same home;
* a non-empty list replaces it. Backends that cannot express a read denial
* report `partial` enforcement instead of pretending, and
* `danger-full-access` confines nothing, so no denial applies there at all.
*/
readDenyPaths?: string[]
}
/** Inputs that select the sandbox policy for one capability call. */
@@ -72,12 +83,15 @@ export class SandboxPolicyService extends Service {
// 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(),
readDenyPaths: z.array(z.string()),
})
/** The deployment default mode — the fallback beneath a session override. */
readonly defaultMode: SandboxMode
/** The absolute `workspace-write` fallback root for calls without a session cwd. */
readonly workspaceRoot: string
/** Absolute paths every confined execution is denied read access to. */
readonly readDenyPaths: readonly string[]
constructor(ctx: Context, config: Config) {
super(ctx, 'sandboxPolicy')
@@ -86,6 +100,12 @@ export class SandboxPolicyService extends Service {
// the process cwd is real branching, resolved absolute either way.
this.defaultMode = config.mode as SandboxMode
this.workspaceRoot = resolveWorkspaceRoot(config.workspaceRoot ?? process.cwd())
// The credential document is the default denial; a configured list
// replaces it. Schemastery fills an omitted array with `[]`, so empty and
// omitted are the same request: protect the default document.
const denyPaths = config.readDenyPaths ?? []
this.readDenyPaths = (denyPaths.length > 0 ? denyPaths : [join(resolveDshHome(), '.env')])
.map(resolveWorkspaceRoot)
}
/**
@@ -102,6 +122,7 @@ export class SandboxPolicyService extends Service {
return {
mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode,
workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot),
readDenyPaths: this.readDenyPaths,
}
}

View File

@@ -10,9 +10,14 @@ import { join, resolve, sep } from 'node:path'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
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 } = {}) {
async function mounted(config: {
mode?: 'read-only' | 'workspace-write' | 'danger-full-access'
workspaceRoot?: string
readDenyPaths?: string[]
} = {}) {
const ctx = new Context()
await ctx.plugin(SandboxPolicyService, config)
return ctx
@@ -41,11 +46,28 @@ describe('SandboxPolicyService', () => {
expect(ctx.sandboxPolicy.workspaceRoot).toBe(resolve('/ws/../ws/./sub'))
})
it('denies reading the harness credential document by default', async () => {
const ctx = await mounted()
// The exact file, not the whole home: the model keeps the documented
// access to its own session log under the same directory.
expect(ctx.sandboxPolicy.readDenyPaths).toEqual([resolve(resolveDshHome(), '.env')])
expect(ctx.sandboxPolicy.resolve().readDenyPaths).toEqual([resolve(resolveDshHome(), '.env')])
})
it('replaces the default with a configured denial list', async () => {
const configured = await mounted({ readDenyPaths: ['/vault/../vault/./keys.env'] })
expect(configured.sandboxPolicy.readDenyPaths).toEqual([resolve('/vault/keys.env')])
// Schemastery fills an omitted array with `[]`, so empty reads as omitted.
const empty = await mounted({ readDenyPaths: [] })
expect(empty.sandboxPolicy.readDenyPaths).toEqual([resolve(resolveDshHome(), '.env')])
})
it('resolves the deployment policy for an agentless call', async () => {
const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/fallback' })
expect(ctx.sandboxPolicy.resolve()).toEqual({
mode: 'workspace-write',
workspaceRoot: resolve('/fallback'),
readDenyPaths: [resolve(resolveDshHome(), '.env')],
})
})
@@ -58,16 +80,19 @@ describe('SandboxPolicyService', () => {
expect(ctx.sandboxPolicy.resolve({ session: first })).toEqual({
mode: 'workspace-write',
workspaceRoot: resolve('/projects/first'),
readDenyPaths: [resolve(resolveDshHome(), '.env')],
})
expect(ctx.sandboxPolicy.resolve({ session: second })).toEqual({
mode: 'read-only',
workspaceRoot: resolve('/projects/second'),
readDenyPaths: [resolve(resolveDshHome(), '.env')],
})
expect(ctx.sandboxPolicy.overrideOf(first)).toBeUndefined()
expect(ctx.sandboxPolicy.overrideOf(second)).toBe('read-only')
expect(ctx.sandboxPolicy.resolve()).toEqual({
mode: 'workspace-write',
workspaceRoot: resolve('/fallback'),
readDenyPaths: [resolve(resolveDshHome(), '.env')],
})
})
@@ -87,6 +112,7 @@ describe('SandboxPolicyService', () => {
expect(ctx.sandboxPolicy.resolve({ session: session('sess-symlink-parent', cwd) })).toEqual({
mode: 'workspace-write',
workspaceRoot: realpathSync.native(physical),
readDenyPaths: [resolve(resolveDshHome(), '.env')],
})
} finally {
rmSync(root, { recursive: true, force: true })
@@ -100,6 +126,7 @@ describe('SandboxPolicyService', () => {
expect(ctx.sandboxPolicy.resolve({ session: active, mode: 'danger-full-access' })).toEqual({
mode: 'danger-full-access',
workspaceRoot: resolve('/projects/approved'),
readDenyPaths: [resolve(resolveDshHome(), '.env')],
})
})

View File

@@ -20,6 +20,9 @@
{
"path": "../sandbox"
},
{
"path": "../../util/paths"
},
{
"path": "../../core/session"
},

View File

@@ -40,6 +40,18 @@ export interface SandboxExecutionPolicy {
mode: SandboxMode
/** Absolute root directory `workspace-write` may write under. */
workspaceRoot: string
/**
* Absolute paths a confined execution must not READ, whatever the mode
* otherwise permits — the harness's own credential document is the
* motivating case, which is why these are exact paths rather than roots:
* denying the whole harness home would also take away the model's
* documented access to its own session log. Not every backend can express
* a read denial (a Landlock allow-list granting `/` cannot subtract from
* itself), so {@link ConfinedArgv.enforcement} drops to `partial` when a
* denial is requested and the selected backend cannot apply it. Never a
* boundary under `danger-full-access`, which confines nothing at all.
*/
readDenyPaths?: readonly string[]
}
/**