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
@@ -22,7 +22,7 @@ The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`,
## The document
dotenv format, parsed with `dotenv` and edited by a line editor that preserves every byte it does not own: `set` rewrites the first assignment of its key in place (dropping later duplicates, which dotenv's last-wins reading would otherwise let override the edit), `unset` removes only the owning line, comments and unrelated lines survive verbatim. Writes go through [`dsh-atomic-write`](../../util/atomic-write/README.md) with mode `0600`.
dotenv format, parsed with `dotenv` and edited by a physical-line editor that preserves every byte it does not own: `set` rewrites the first assignment of its key in place with that line's own ending (dropping later duplicates, which dotenv's last-wins reading would otherwise let override the edit), `unset` removes only the owning line, and comments, unrelated lines, CRLF endings, and the continuation lines of another key's quoted multi-line value all survive verbatim. Every write first re-reads the document under the cross-process writer lock of [`dsh-atomic-write`](../../util/atomic-write/README.md) and publishes anything it had not observed, then commits atomically with mode `0600` under an owner-only (`0700`) directory — so a concurrent writer or an external edit inside the watcher's debounce window is folded in rather than overwritten.
Values are rendered in the narrowest style dotenv reads back verbatim — bare, then single-quoted (fully literal), then double-quoted (only without backslashes, which double-quote reading expands). A value no style can represent, and any entry that already spans multiple physical lines, fails loud instead of being corrupted silently. An empty stored value is absent, per the seam rule.
@@ -30,6 +30,15 @@ Values are rendered in the narrowest style dotenv reads back verbatim — bare,
External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable file at boot fails loud. Keys that are not POSIX identifiers are preserved file content the seam cannot address.
## Security boundary
The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, so under the shipped `danger-full-access` default they can read this file exactly like any other file the user owns. Two things narrow that:
- A **confining sandbox mode** denies the credential document specifically: [`dsh-sandbox-policy`](../../sandbox/sandbox-policy/README.md) defaults `readDenyPaths` to `$DSH_HOME/.env`, and the Seatbelt and bwrap backends enforce it (Landlock cannot subtract from its own `/` read grant and reports `partial`). The denial names the file, not the home, so the model keeps its documented access to its own session log.
- The harness never hands the model a resolved path to the document, and never loads it into the process environment (see [app-boot's Personal config](../../ui/app-boot/README.md#personal-config)).
Neither makes an unconfined agent safe. A deployment that must keep provider keys away from its own agent should run a confining mode; an OS-keychain provider — a store the model's processes cannot read at all — is the deferred answer and belongs beside this provider as a sibling package.
## Model Experience
Indirectly, through the consuming LLM adapters: stored values authorize their provider requests, and the adapter owns every model-visible surface.
@@ -40,7 +49,9 @@ No direct invalidation; credentials never enter a request prefix.
## Known Limitations and Deferred Work
- **Multi-line entries refuse `set`/`unset`** — the line editor will not rewrite an entry it would corrupt; edit the file directly.
- **Multi-line entries refuse `set`/`unset`** — the line editor will not rewrite an entry it would corrupt; `describe` reports them `writable: false` and edits must go to the file directly.
- **Same-reference concurrent writes are last-write-wins** — the writer lock and the read-modify-write keep concurrent writers from dropping each other's entries, but two writers editing one reference still resolve to the later write; there is no revision check.
- **A same-UID process can read the document** — see [Security boundary](#security-boundary): only a confining sandbox mode denies it, and an OS-keychain provider is deferred.
- **Unrepresentable values fail loud** — control characters, or a mix of both quote styles with backslashes, cannot round-trip the dotenv line format.
- **Environment changes are invisible** — `process.env` is read live per resolution, but no event can announce a change there.
- **Atomic, not crash-durable** — inherited from `dsh-atomic-write`; the store re-reads on boot.
+6 -1
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],
}
+22 -1
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(' ')]
}
@@ -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.
@@ -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.
@@ -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.
@@ -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"
+22 -1
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,
}
}
@@ -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')],
})
})
@@ -20,6 +20,9 @@
{
"path": "../sandbox"
},
{
"path": "../../util/paths"
},
{
"path": "../../core/session"
},
+12
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[]
}
/**