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:
@@ -11,7 +11,6 @@ import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
@@ -75,11 +74,8 @@ function runResult(exitCode: number | null, stderr: string): BashRunResult {
|
||||
return { exitCode, signal: null, timedOut: false, aborted: false, timeoutMs: 1000, stdout: output(''), stderr: output(stderr) }
|
||||
}
|
||||
|
||||
/** The policy home's default read denial: the harness credential document. */
|
||||
const DEFAULT_DENY = [resolve(resolveDshHome(), '.env')]
|
||||
|
||||
function executionPolicy(mode: SandboxMode, workspaceRoot = resolve(process.cwd())): SandboxExecutionPolicy {
|
||||
return { mode, workspaceRoot, readDenyPaths: DEFAULT_DENY }
|
||||
return { mode, workspaceRoot }
|
||||
}
|
||||
|
||||
describe('the provider hand-off', () => {
|
||||
@@ -90,7 +86,7 @@ describe('the provider hand-off', () => {
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
expect(calls).toEqual([{
|
||||
argv: ['bash', '-c', 'echo \'a b\' "c\'d"'],
|
||||
policy: { mode: 'read-only', workspaceRoot: resolve(process.cwd()), readDenyPaths: DEFAULT_DENY },
|
||||
policy: { mode: 'read-only', workspaceRoot: resolve(process.cwd()) },
|
||||
}])
|
||||
})
|
||||
|
||||
@@ -107,7 +103,7 @@ describe('the provider hand-off', () => {
|
||||
const { bash, calls } = await setup({ mode: 'workspace-write' })
|
||||
const result = await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(process.cwd()), readDenyPaths: DEFAULT_DENY })
|
||||
expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(process.cwd()) })
|
||||
})
|
||||
|
||||
it('an explicit workspaceRoot on the policy wins', async () => {
|
||||
|
||||
@@ -2211,7 +2211,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SandboxExecutionPolicy',
|
||||
declaration: 'export interface SandboxExecutionPolicy {\n mode: SandboxMode;\n workspaceRoot: string;\n readDenyPaths?: readonly string[];\n}',
|
||||
declaration: 'export interface SandboxExecutionPolicy {\n mode: SandboxMode;\n workspaceRoot: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SandboxMode',
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/credentials/credentials-local/README.md
|
||||
README.md: 2288d6d7133a7f356823e3e4f28746cfd28b2597
|
||||
README.zh.md: 959322c9ec670ed76b89f1f3a19246191b3ec02c
|
||||
README.md: 126140b10719dc6f7bc458a118ba1feb1f440270
|
||||
README.zh.md: c22575115ab44b5e86a847ffe8f1fa1a795b580d
|
||||
|
||||
@@ -32,12 +32,9 @@ External edits publish `credentials/updated` per changed reference after the sna
|
||||
|
||||
## 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:
|
||||
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, and no sandbox mode singles it out. What the harness does hold to is narrower: it 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)), so reaching the value takes a deliberate read of a path the agent was not given.
|
||||
|
||||
- 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.
|
||||
That is discretion, not a boundary. A deployment that must keep provider keys away from its own agent cannot get there with file permissions; 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
|
||||
|
||||
|
||||
@@ -32,12 +32,9 @@ dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一
|
||||
|
||||
## 安全边界
|
||||
|
||||
文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,因此在出厂默认的 `danger-full-access` 下,它们读这个文件与读该用户拥有的任何其他文件毫无二致。有两件事收窄了这一点:
|
||||
文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,因此在出厂默认的 `danger-full-access` 下,它们读这个文件与读该用户拥有的任何其他文件毫无二致,也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config)),因此要拿到这个值,需要刻意去读一条并未交给 agent 的路径。
|
||||
|
||||
- **受限沙箱模式**会专门拒绝凭据文档:[`dsh-sandbox-policy`](../../sandbox/sandbox-policy/README.md) 把 `readDenyPaths` 默认为 `$DSH_HOME/.env`,Seatbelt 与 bwrap 后端会执行它(Landlock 无法从自己的 `/` 读授权中扣除,只能报 `partial`)。这条拒绝点名的是该文件而非整个 home,因此模型对自己会话日志的既定访问不受影响。
|
||||
- harness 绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config))。
|
||||
|
||||
这两者都不能让未受限的 agent 变得安全。必须让提供方密钥远离自身 agent 的部署应当运行受限模式;OS 钥匙串 provider——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本 provider 并列。
|
||||
这是审慎,不是边界。必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到;OS 钥匙串 provider——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本 provider 并列。
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve, sep } from 'node:path'
|
||||
@@ -113,9 +112,6 @@ function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
/** The policy home's default read denial: the harness credential document. */
|
||||
const DEFAULT_DENY = [resolve(resolveDshHome(), '.env')]
|
||||
|
||||
describe('session cwd resolution', () => {
|
||||
const execution = (cwd?: string) => cwd === undefined
|
||||
? {}
|
||||
@@ -767,13 +763,13 @@ describe('sandbox escalation surface (write/edit)', () => {
|
||||
it('a plain write stamps the default mode with the calling session root', async () => {
|
||||
const { ctx, fs } = await setupConfining()
|
||||
await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent())
|
||||
expect(fs.stamped).toEqual([{ mode: 'workspace-write', workspaceRoot: resolve('/session-project'), readDenyPaths: DEFAULT_DENY }])
|
||||
expect(fs.stamped).toEqual([{ mode: 'workspace-write', workspaceRoot: resolve('/session-project') }])
|
||||
})
|
||||
|
||||
it('a standing session override folds onto the stamp', async () => {
|
||||
const { ctx, fs } = await setupConfining()
|
||||
await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent([{ type: 'sandbox/mode', data: { mode: 'read-only' } }]))
|
||||
expect(fs.stamped).toEqual([{ mode: 'read-only', workspaceRoot: resolve('/session-project'), readDenyPaths: DEFAULT_DENY }])
|
||||
expect(fs.stamped).toEqual([{ mode: 'read-only', workspaceRoot: resolve('/session-project') }])
|
||||
})
|
||||
|
||||
it('a denied write maps to the shared marker plus the escalation hint (isError)', async () => {
|
||||
@@ -806,7 +802,7 @@ describe('sandbox escalation surface (write/edit)', () => {
|
||||
agent: escalationAgent() as never,
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
expect(fs.stamped).toEqual([{ mode: 'danger-full-access', workspaceRoot: resolve('/session-project'), readDenyPaths: DEFAULT_DENY }])
|
||||
expect(fs.stamped).toEqual([{ mode: 'danger-full-access', workspaceRoot: resolve('/session-project') }])
|
||||
})
|
||||
|
||||
it('a rejected escalation fails closed with its own text and never mutates', async () => {
|
||||
|
||||
@@ -228,12 +228,7 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
const selected = this.selectRunner(policy.mode)
|
||||
return {
|
||||
argv: [...this.runnerArgv(selected.runner, policy), '--', ...argv],
|
||||
// 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,
|
||||
enforcement: selected.enforcement,
|
||||
denialSignatures: DENIAL_SIGNATURES[selected.runner],
|
||||
runnerFailureSignatures: RUNNER_FAILURE_SIGNATURES[selected.runner],
|
||||
}
|
||||
|
||||
@@ -5,14 +5,9 @@
|
||||
*/
|
||||
|
||||
import { grantArgs as landlockGrantArgs } from 'node-addon-landlock-run'
|
||||
import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
|
||||
import { 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.
|
||||
@@ -24,10 +19,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -37,10 +28,6 @@ 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)
|
||||
@@ -67,13 +54,5 @@ 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,27 +62,6 @@ 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.
|
||||
@@ -329,15 +308,6 @@ describe('the default landlock probe (launcher CLI contract)', () => {
|
||||
expect(sandbox.confine(['true'], RO).enforcement).toBe('partial')
|
||||
})
|
||||
|
||||
it('reports partial enforcement when a read denial is requested it cannot express', async () => {
|
||||
const launcher = fakeLauncher()
|
||||
const { sandbox } = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher })
|
||||
// Fully enforced for the write policy, yet the read denial is
|
||||
// unexpressible in an allow-list that already grants `/` for reads.
|
||||
expect(sandbox.confine(['true'], RO).enforcement).toBe('full')
|
||||
expect(sandbox.confine(['true'], { ...RO, readDenyPaths: ['/ws/secret.env'] }).enforcement).toBe('partial')
|
||||
})
|
||||
|
||||
it('reads a failing launcher as unusable: the chain ends and fails closed', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-'))
|
||||
const launcher = join(dir, 'landlock-run')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
@@ -70,37 +70,6 @@ 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.
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/sandbox/sandbox-policy/README.md
|
||||
README.md: 297dd7d5210bb30963a162c6a55a598c6d522aaf
|
||||
README.zh.md: 1de92eb81409a7fabb25de94eb5372f0f16afb6f
|
||||
README.md: dca54330bc888af9ecac21aa92019d8a2b0140bd
|
||||
README.zh.md: a201d48c81f563fc3d85495e964bb67432517a3c
|
||||
|
||||
@@ -13,12 +13,6 @@ 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.
|
||||
|
||||
@@ -13,12 +13,6 @@
|
||||
- `mode`:部署默认 `SandboxMode`(`read-only`/`workspace-write`/`danger-full-access`),加载时验证。默认为 `read-only`(故障安全)。
|
||||
- `workspaceRoot`:无 agent(智能体)的调用或没有 cwd 的会话在 `workspace-write` 下可写入的回退目录。默认为 `process.cwd()`;无论显式配置还是采用默认值,都会解析为其绝对文件系统标识。普通 agent 调用改用其会话头中不可变的 `cwd`。
|
||||
|
||||
## 读取拒绝
|
||||
|
||||
`readDenyPaths` 列出**受约束**执行绝不可读取的绝对路径,无论其模式在其他方面允许什么。省略(或为空)时拒绝 harness 凭据文档 `$DSH_HOME/.env`;非空列表则替换该默认值。拒绝项有意点名确切路径而非根目录:拒绝整个 harness home 会连带拿走模型对自己会话日志的既定访问。
|
||||
|
||||
强制执行的形态由后端决定。Seatbelt 追加一条尾部 `deny file-read* file-write*`(最后匹配的规则胜出),bwrap 在任何工作区绑定之后把 `/dev/null` 映射到每个路径上;Landlock 的授权是纯粹的允许列表,`/` 上的读授权无法被扣除,因此 `confine()` 把强制执行报为 `partial`,而不是假装该边界存在。`danger-full-access` 根本不做任何约束,那里也就没有任何拒绝适用——凭据文档届时只受自身文件权限模式保护,而这挡不住同 UID 的工具进程。
|
||||
|
||||
## 接口
|
||||
|
||||
- `ctx.sandboxPolicy.resolve({ session?, mode? })`:解析一项完整的逐调用策略。显式批准的模式优先于会话最后一条 `sandbox/mode` 事件,后者又优先于 `defaultMode`;会话不可变的 `cwd` 会先按文件系统语义规范化,再成为 `workspaceRoot`,否则使用配置的回退值。规范化先于词法归一化,因此 `symlink/..` 与进程工作目录解析保持一致。
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
"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"
|
||||
@@ -38,7 +37,6 @@
|
||||
},
|
||||
"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"
|
||||
|
||||
@@ -14,11 +14,10 @@
|
||||
* @module @deepseek-ai/dsh-sandbox-policy
|
||||
*/
|
||||
|
||||
import { join, resolve as resolvePath } from 'node:path'
|
||||
import { 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'
|
||||
|
||||
@@ -50,16 +49,6 @@ 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. */
|
||||
@@ -83,15 +72,12 @@ 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')
|
||||
@@ -100,12 +86,6 @@ 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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,7 +102,6 @@ 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,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')],
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -20,9 +20,6 @@
|
||||
{
|
||||
"path": "../sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
|
||||
@@ -40,18 +40,6 @@ 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[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user