revert(sandbox): withdraw the credential-document read denial

The `readDenyPaths` policy field shipped in the previous commit broke Linux
confinement outright. bwrap has to create the `/dev/null` bind's mount point
inside a tree its own profile has already made read-only, so it refused the
entire confinement whenever the parent directory was absent — every host that
has not stored a credential yet, including a fresh install:

  bwrap: Can't mkdir parents for /home/runner/.dsh/.env: Read-only file system

which the executor correctly classifies as SANDBOX_UNAVAILABLE, so every
confined bash call failed closed. Landlock cannot subtract from its own `/`
read grant, so it reported `partial` enforcement on every confined call for a
file it never hid, with no way to switch the denial off (schemastery fills an
omitted array with `[]`, so empty and omitted were indistinguishable).

A protection that breaks confinement where it works and misreports it where it
does not is worse than a documented absence. Revert the field, both expressible
backends, the enforcement downgrade, and the policy default; state the residue
plainly in the credentials-local READMEs — file mode stops other OS users, not
the model — and keep the OS-keychain provider recorded as the real answer.

The narrower discipline stands: no surface hoists the credential document into
`process.env`, and the model is never handed a resolved path to it.
This commit is contained in:
Yichen Jiang
2026-07-30 17:09:42 +08:00
parent 52ae578982
commit a90ccc4453
27 changed files with 38 additions and 262 deletions

View File

@@ -10,14 +10,9 @@ 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
readDenyPaths?: string[]
} = {}) {
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
@@ -46,35 +41,11 @@ 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('defaults the denial list under programmatic construction too', () => {
// Constructing the service directly bypasses Schemastery, so the field
// arrives undefined rather than as the empty array the schema fills.
const service = new SandboxPolicyService(new Context(), {})
expect(service.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')],
})
})
@@ -87,19 +58,16 @@ 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')],
})
})
@@ -119,7 +87,6 @@ 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 })
@@ -133,7 +100,6 @@ 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')],
})
})