feat(config)!: one ordering for configuration sources, and a bootstrap deny rule

$DSH_HOME/.env had just become an ordinary environment layer, which left the
harness resolving user-facing values from a flattened process.env that could
no longer say where a value came from. A key stored through the web page
stayed shadowed by an older key in the user's own .env. An endpoint could be
redirected by the project: the invoking directory's .env is materialized like
every other layer, and a base URL decides where a resolved API key is sent, so
a DEEPSEEK_BASE_URL written into a model-editable workspace would send the
user's credential — and the prompts carrying their code — to whatever host
that file named.

Give every user-facing value one ordering, with four kinds of source:

  explicit for this run     per-operation override, CLI argument
  > authored by deployment  --config / --config-replace
  > this launch's shell     inherited process environment
  > product-managed store   settings.yaml, .credentials.yaml
  > discovered file         $DSH_HOME/.env
  > defaults                schema default, shipped base, public default

The domains differ only in which tiers exist. The earlier split — credentials
ranking the environment over the managed file while settings ranked over the
environment — was inconsistent: the distinguishing fact is who authored the
source, not the domain.

packages/util/environment owns an immutable snapshot with per-layer
provenance. getFrom(name, sources) searches only the layers a caller names,
and omitting one is a refusal rather than a demotion: the adapters ask for
['process', 'user-env'], so no reordering can let a project file back into a
decision it was excluded from.

isBootstrapOnly rejects, before anything is materialized, any .env setting a
variable that governs how a process launches (PATH, SHELL, NODE_OPTIONS,
LD_PRELOAD), where code or model-visible instructions load from (the whole
DSH_* namespace, HOME, XDG_*), or how the network is reached (proxy and CA
variables). The namespace is denied wholesale so a switch added later cannot
become settable by being forgotten, and there is no opt-out.

verify-config-source-ownership keeps both rules: no unregistered process.env
read under packages/*/*/src (26 allowlisted with reasons), and no apiKey,
baseURL, or headers inlined from the environment in shipped Cordis config —
removing those inlines is what makes the deployment tier meaningful.
This commit is contained in:
Yichen Jiang
2026-08-04 16:17:32 +08:00
parent 8ddc53f7a0
commit 0512b12714
59 changed files with 1241 additions and 165 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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/util/environment/README.md
README.md: f642aa715c87878b2eaab9f034fb18163a6fbd2e
README.zh.md: a095730dbc8c2a4e7dc8dc57dc2930685c8fb453

View File

@@ -0,0 +1,42 @@
# dsh-environment
English | [中文](README.zh.md)
This run's environment as one immutable snapshot that remembers **which layer supplied each value**. Consumers resolve user-facing values against it instead of `process.env`, because the layers are not equally trusted and a flattened view cannot tell them apart.
| Layer | Source id | What it is |
|---|---|---|
| Inherited process environment | `process` | What the launching shell, CI job, or container passed in — this run's explicit intent |
| `<invocation cwd>/.env` | `project-env` | Whatever the project directory happens to contain; a model working in that workspace can write it |
| `$DSH_HOME/.env` | `user-env` | The user's own machine-level defaults |
Values do also reach `process.env` — a user's `--config` tree and third-party libraries read it — but that flattened view is not the authority for anything the harness resolves.
## Resolving
`get(name)` searches every layer, most trusted first. `getFrom(name, sources)` searches only the layers the caller trusts.
**Omitting a layer is a refusal, not a demotion.** A base URL decides where a resolved API key is sent, so the LLM adapters ask for `['process', 'user-env']`: no future reordering can let a project file redirect a credential, because that layer is never consulted at all.
```ts
import type { Context } from 'cordis'
import { environmentOf } from '@deepseek-ai/dsh-environment'
declare const ctx: Context
const endpoint = environmentOf(ctx).getFrom('DEEPSEEK_BASE_URL', ['process', 'user-env'])?.value
```
`environmentOf(ctx)` returns the launcher's snapshot when the product CLI booted the tree, and otherwise the inherited environment as the only layer. That fallback does not weaken the rules: an SDK host or a bare `cordis.yml` discovered no files, so everything it has really is the environment it was launched with.
## Bootstrap variables
`isBootstrapOnly(name)` names the variables only the inherited environment may set. The launcher rejects a `.env` that declares one, before applying anything.
A bootstrap variable decides **how a process launches** (`PATH`, `SHELL`, `NODE_OPTIONS`, `NODE_PATH`, `LD_PRELOAD`, `LD_LIBRARY_PATH`, `DYLD_*`), **where code or model-visible instructions load from** (the whole `DSH_*` namespace, `HOME`, `USERPROFILE`, `XDG_*`), or **how the network is reached and trusted** (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`, `SSL_CERT_FILE`, `SSL_CERT_DIR`, `NODE_EXTRA_CA_CERTS`). Matching is case-insensitive, so `https_proxy` is not a bypass.
The whole `DSH_*` namespace is denied rather than an audited subset: the harness's own switches — the permission mode, the agents home, the bundled skill root — are exactly what a hostile project would want, and a switch added later must not become settable by forgetting to list it.
## Known Limitations and Deferred Work
- **The snapshot is not a subprocess boundary** — every layer is also materialized into `process.env`, so ordinary project variables still reach child processes under [`dsh-subprocess`](../../subprocess/subprocess/README.md)'s scrub. Bootstrap variables cannot come from a file at all, but a project `.env` can still set, say, `GIT_SSH_COMMAND` for the tools an agent runs.
- **No per-workspace layer** — the project layer is the *invoking* directory, fixed at launch. A workspace selected later in the Web UI contributes nothing, deliberately: following it would let a model's own workspace change the harness environment mid-session.

View File

@@ -0,0 +1,42 @@
# dsh-environment
[English](README.md) | 中文
把本次运行的环境冻结为一份不可变快照,并记住**每个值来自哪一层**。消费方用它而不是 `process.env` 解析面向用户的值,因为各层的可信程度并不相同,而压平后的视图无法区分它们。
| 层 | 来源 id | 它是什么 |
|---|---|---|
| 继承的进程环境 | `process` | 启动 shell、CI 任务或容器传入的东西——本次运行的明确意图 |
| `<invocation cwd>/.env` | `project-env` | 项目目录里恰好有的东西;在该工作区里工作的模型可以写它 |
| `$DSH_HOME/.env` | `user-env` | 用户自己的机器级默认值 |
这些值同样会进入 `process.env`——用户自己的 `--config` 树和第三方库要读它——但那份压平的视图不是 harness 解析任何值的依据。
## 解析
`get(name)` 按可信度从高到低搜索所有层。`getFrom(name, sources)` 只搜索调用方信任的层。
**省略某一层是拒绝,不是降级。** base URL 决定已解析的 API key 被发往何处,因此 LLM 适配器请求的是 `['process', 'user-env']`:后续任何重新排序都无法让项目文件重定向凭据,因为那一层根本不会被查询。
```ts
import type { Context } from 'cordis'
import { environmentOf } from '@deepseek-ai/dsh-environment'
declare const ctx: Context
const endpoint = environmentOf(ctx).getFrom('DEEPSEEK_BASE_URL', ['process', 'user-env'])?.value
```
当产品 CLI命令行界面启动了这棵树时`environmentOf(ctx)` 返回启动器的快照否则返回只含继承环境的那一层。该回退并不削弱规则SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,因此它拥有的一切确实就是它被启动时的环境。
## bootstrap 变量
`isBootstrapOnly(name)` 给出只有继承环境才能设置的变量。启动器一旦发现某个 `.env` 声明了其中之一,就会在应用任何内容之前拒绝启动。
bootstrap 变量决定**进程如何启动**`PATH``SHELL``NODE_OPTIONS``NODE_PATH``LD_PRELOAD``LD_LIBRARY_PATH``DYLD_*`)、**代码或模型可见的指令从哪里加载**(整个 `DSH_*` 命名空间、`HOME``USERPROFILE``XDG_*`),或者**网络如何抵达与信任**`HTTP_PROXY``HTTPS_PROXY``ALL_PROXY``NO_PROXY``SSL_CERT_FILE``SSL_CERT_DIR``NODE_EXTRA_CA_CERTS`)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。
整个 `DSH_*` 命名空间被拒绝而不是只拒绝一份经过审查的子集harness 自己的开关——权限模式、agents home、内置 skill技能根目录——恰恰是敌意项目最想要的而后来新增的开关不能因为忘记登记就变得可设置。
## Known Limitations and Deferred Work
- **快照不是子进程边界**:每一层同样会被物化进 `process.env`,因此普通的项目变量仍会按 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 的清洗规则抵达子进程。bootstrap 变量完全不能来自文件,但项目 `.env` 仍可以为 agent 运行的工具设置诸如 `GIT_SSH_COMMAND` 之类的变量。
- **没有按工作区划分的层**:项目层是*调用*目录,在启动时固定。之后在 Web UI 中选择的工作区不贡献任何内容,这是刻意的:跟随它等于让模型自己的工作区在会话中途改变 harness 的环境。

View File

@@ -0,0 +1,37 @@
{
"name": "@deepseek-ai/dsh-environment",
"description": "Immutable launch-time environment snapshot with per-layer provenance for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,178 @@
/**
* The launch-time environment as one immutable snapshot that remembers which
* layer supplied each value. The harness resolves user-facing values against
* this rather than against `process.env`, because the layers differ in how
* much they are trusted: an inherited variable is this run's explicit intent,
* a file discovered under the invoking directory is whatever the project
* happens to contain, and a consumer that cannot tell them apart cannot make
* that distinction.
*
* Values still reach `process.env` as well — a user's own `--config` tree and
* third-party libraries read it — but that flattened view is not the
* authority for anything the harness itself resolves.
* @module @deepseek-ai/dsh-environment
*/
import type { Context } from 'cordis'
/**
* Which layer supplied a value, from most to least trusted: the environment
* this process inherited, the invoking directory's `.env`, the Harness home's
* `.env`.
*/
export type EnvironmentSource = 'process' | 'project-env' | 'user-env'
/** Layer order, most trusted first — the default search order of {@link EnvironmentSnapshot.get}. */
export const ENVIRONMENT_SOURCES: readonly EnvironmentSource[] = ['process', 'project-env', 'user-env']
/** One resolved variable and the layer it came from. */
export interface EnvironmentEntry {
/** The value as the layer supplied it; may be empty, which each owner judges for itself. */
value: string
/** The layer that supplied it. */
source: EnvironmentSource
/** Absolute path of the file that supplied it; absent for `process`. */
path?: string
}
/** One environment layer's identity, for diagnostics. */
export interface EnvironmentLayer {
source: EnvironmentSource
/** Absolute path of the file behind this layer; absent for `process`. */
path?: string
}
/**
* The frozen environment of one launch. Construct through
* {@link createEnvironmentSnapshot}; nothing mutates it afterwards, so a
* later `chdir`, workspace switch, or resumed session observes the same
* values a consumer resolved at boot.
*/
export interface EnvironmentSnapshot {
/**
* Resolve one name across every layer, most trusted first.
* @param name - the variable name.
* @returns the winning entry, or `undefined` when no layer supplies it.
*/
get(name: string): EnvironmentEntry | undefined
/**
* Resolve one name across only the layers the caller trusts for this
* decision. Omitting a layer is a refusal, not a demotion: a routing field
* that must never come from a project directory omits `project-env` so no
* ordering change can let it back in.
* @param name - the variable name.
* @param sources - the layers to search, in the caller's own priority order.
* @returns the first matching entry, or `undefined`.
*/
getFrom(name: string, sources: readonly EnvironmentSource[]): EnvironmentEntry | undefined
/** The layers this snapshot was built from, most trusted first. */
readonly layers: readonly EnvironmentLayer[]
}
/** One layer's raw contents, as {@link createEnvironmentSnapshot} receives them. */
export interface EnvironmentLayerInput {
source: EnvironmentSource
/** Absolute path of the file behind this layer; omit for `process`. */
path?: string
values: Readonly<Record<string, string>>
}
/**
* Build the snapshot from each layer's contents.
* @param layers - the layers in any order; the result searches them by {@link ENVIRONMENT_SOURCES}.
* @returns the immutable snapshot.
*/
export function createEnvironmentSnapshot(layers: readonly EnvironmentLayerInput[]): EnvironmentSnapshot {
// Copied per layer so a later mutation of `process.env` — or of a caller's
// own object — cannot change what this snapshot reports.
const bySource = new Map<EnvironmentSource, { path?: string; values: Map<string, string> }>()
for (const layer of layers) {
bySource.set(layer.source, {
...layer.path === undefined ? {} : { path: layer.path },
values: new Map(Object.entries(layer.values)),
})
}
const getFrom = (name: string, sources: readonly EnvironmentSource[]): EnvironmentEntry | undefined => {
for (const source of sources) {
const layer = bySource.get(source)
const value = layer?.values.get(name)
if (value === undefined) continue
return { value, source, ...layer?.path === undefined ? {} : { path: layer.path } }
}
return undefined
}
return {
get: name => getFrom(name, ENVIRONMENT_SOURCES),
getFrom,
layers: ENVIRONMENT_SOURCES
.filter(source => bySource.has(source))
.map((source): EnvironmentLayer => {
const path = bySource.get(source)?.path
return { source, ...path === undefined ? {} : { path } }
}),
}
}
/** Context slot the launcher fills with this run's snapshot before any config entry mounts. */
export const DSH_ENVIRONMENT_KEY = 'launcherEnvironment'
/**
* The snapshot to resolve against, whatever booted this tree: the launcher's
* when the product CLI provided one, otherwise the inherited environment
* alone.
*
* The fallback does not weaken the layer rules — it applies the same rules to
* a host that has exactly one layer. An SDK embedder or a bare `cordis.yml`
* never discovered a project or user file, so everything it has really is the
* environment it was launched with, and `getFrom(..., ['process'])` is exactly
* right for it.
* @param ctx - the consuming plugin's context.
* @returns the snapshot to resolve user-facing values against.
*/
export function environmentOf(ctx: Context): EnvironmentSnapshot {
return ctx.get(DSH_ENVIRONMENT_KEY)
?? createEnvironmentSnapshot([{ source: 'process', values: process.env as Record<string, string> }])
}
declare module 'cordis' {
interface Context {
/** Launcher-owned snapshot of this run's environment; absent in compositions the product CLI did not boot. */
launcherEnvironment?: EnvironmentSnapshot
}
}
/** Exact names no discovered file may set. */
const BOOTSTRAP_NAMES = new Set([
// Process launch and module resolution.
'PATH', 'HOME', 'USERPROFILE', 'SHELL',
'NODE_OPTIONS', 'NODE_PATH', 'NODE_EXTRA_CA_CERTS',
'LD_PRELOAD', 'LD_LIBRARY_PATH',
// Network reach and trust.
'SSL_CERT_FILE', 'SSL_CERT_DIR',
'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
])
/** Name prefixes no discovered file may set. */
const BOOTSTRAP_PREFIXES = ['DSH_', 'XDG_', 'DYLD_']
/**
* Whether a variable may come only from the inherited process environment.
*
* A bootstrap variable decides how a process launches (`PATH`, `NODE_OPTIONS`,
* `LD_PRELOAD`), where code or model-visible instructions load from (`DSH_*`
* covers the Harness home, the agents home, and the bundled skill root), or
* how the network is reached and trusted (proxy and CA variables). A file the
* harness merely finds — including one a model can write inside the workspace
* — must never set them, so they are rejected at load rather than ranked
* below another layer.
*
* The whole `DSH_*` namespace is denied rather than an audited subset: the
* harness's own switches are exactly the ones a hostile project would want,
* and a new switch must not become settable by forgetting to list it.
* @param name - the variable name.
* @returns true when only the inherited environment may supply it.
*/
export function isBootstrapOnly(name: string): boolean {
const upper = name.toUpperCase()
return BOOTSTRAP_NAMES.has(upper) || BOOTSTRAP_PREFIXES.some(prefix => upper.startsWith(prefix))
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-environment`.
* @module @deepseek-ai/dsh-environment/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-environment'
/** Cordis companion plugin name. */
export const name = 'environment-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the snapshot is frozen before any fiber starts and this package owns no
* event stream or mutable runtime data; its lookup and rejection rules are enforced by unit tests.
*/
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 */

View File

@@ -0,0 +1,118 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import {
createEnvironmentSnapshot, DSH_ENVIRONMENT_KEY, ENVIRONMENT_SOURCES, environmentOf, isBootstrapOnly,
} from '../src/index.ts'
const layered = createEnvironmentSnapshot([
{ source: 'process', values: { SHARED: 'from-process', ONLY_PROCESS: 'p' } },
{ source: 'project-env', path: '/work/.env', values: { SHARED: 'from-project', ONLY_PROJECT: 'j' } },
{ source: 'user-env', path: '/home/.dsh/.env', values: { SHARED: 'from-user', ONLY_USER: 'u' } },
])
describe('createEnvironmentSnapshot', () => {
it('resolves across every layer, most trusted first, and reports the winning source', () => {
expect(layered.get('SHARED')).toEqual({ value: 'from-process', source: 'process' })
expect(layered.get('ONLY_PROJECT')).toEqual({ value: 'j', source: 'project-env', path: '/work/.env' })
expect(layered.get('ONLY_USER')).toEqual({ value: 'u', source: 'user-env', path: '/home/.dsh/.env' })
expect(layered.get('ABSENT')).toBeUndefined()
})
it('treats an omitted layer as invisible, not merely lower', () => {
// The point of getFrom: a routing field that must never come from a
// project directory cannot be reached by reordering, only by listing it.
expect(layered.getFrom('ONLY_PROJECT', ['process', 'user-env'])).toBeUndefined()
expect(layered.getFrom('SHARED', ['user-env', 'process'])).toEqual({
value: 'from-user', source: 'user-env', path: '/home/.dsh/.env',
})
expect(layered.getFrom('SHARED', [])).toBeUndefined()
})
it('lists its layers in trust order with their paths', () => {
expect(layered.layers).toEqual([
{ source: 'process' },
{ source: 'project-env', path: '/work/.env' },
{ source: 'user-env', path: '/home/.dsh/.env' },
])
expect(createEnvironmentSnapshot([{ source: 'process', values: {} }]).layers).toEqual([{ source: 'process' }])
})
it('copies each layer, so a later mutation of the source object cannot change it', () => {
const values: Record<string, string> = { KEY: 'first' }
const snapshot = createEnvironmentSnapshot([{ source: 'process', values }])
values.KEY = 'second'
values.LATE = 'added'
expect(snapshot.get('KEY')).toEqual({ value: 'first', source: 'process' })
expect(snapshot.get('LATE')).toBeUndefined()
})
it('keeps an empty value as a present value, for its owner to judge', () => {
const snapshot = createEnvironmentSnapshot([{ source: 'process', values: { EMPTY: '' } }])
expect(snapshot.get('EMPTY')).toEqual({ value: '', source: 'process' })
})
it('orders lookups by ENVIRONMENT_SOURCES regardless of construction order', () => {
const reversed = createEnvironmentSnapshot([
{ source: 'user-env', path: '/u', values: { K: 'u' } },
{ source: 'process', values: { K: 'p' } },
])
expect(ENVIRONMENT_SOURCES).toEqual(['process', 'project-env', 'user-env'])
expect(reversed.get('K')).toEqual({ value: 'p', source: 'process' })
})
})
describe('environmentOf', () => {
it('returns the launcher snapshot when the product CLI provided one', () => {
const ctx = new Context()
ctx.provide(DSH_ENVIRONMENT_KEY, layered)
expect(environmentOf(ctx)).toBe(layered)
})
it('falls back to the inherited environment as the only layer', () => {
vi.stubEnv('DSH_ENV_SPEC_FALLBACK', 'ambient')
try {
const snapshot = environmentOf(new Context())
expect(snapshot.get('DSH_ENV_SPEC_FALLBACK')).toEqual({ value: 'ambient', source: 'process' })
// A host that discovered no files has exactly one layer, so the trusted
// lookups every consumer makes still find what it was launched with.
expect(snapshot.getFrom('DSH_ENV_SPEC_FALLBACK', ['process', 'user-env'])?.value).toBe('ambient')
expect(snapshot.layers).toEqual([{ source: 'process' }])
} finally {
vi.unstubAllEnvs()
}
})
})
describe('isBootstrapOnly', () => {
it.each([
'PATH', 'HOME', 'USERPROFILE', 'SHELL',
'NODE_OPTIONS', 'NODE_PATH', 'NODE_EXTRA_CA_CERTS',
'LD_PRELOAD', 'LD_LIBRARY_PATH',
'SSL_CERT_FILE', 'SSL_CERT_DIR',
'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
])('rejects %s, which decides how the process starts or reaches the network', (name) => {
expect(isBootstrapOnly(name)).toBe(true)
})
it.each([
['DSH_HOME', 'the harness home'],
['DSH_PERMISSION_MODE', 'the permission mode'],
['DSH_AGENTS_HOME', 'a model-visible instruction root'],
['DSH_ANYTHING_ADDED_LATER', 'a switch that does not exist yet'],
['XDG_CONFIG_HOME', 'a state root'],
['DYLD_INSERT_LIBRARIES', 'a library preload'],
])('rejects the whole namespace: %s (%s)', (name) => {
expect(isBootstrapOnly(name)).toBe(true)
})
it('matches case-insensitively, so a lowercase proxy name is not a bypass', () => {
expect(isBootstrapOnly('https_proxy')).toBe(true)
expect(isBootstrapOnly('dsh_permission_mode')).toBe(true)
})
it('allows ordinary variables, including provider credentials and endpoints', () => {
for (const name of ['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL', 'EXA_API_KEY', 'MY_PROJECT_FLAG', 'PATHS']) {
expect(isBootstrapOnly(name)).toBe(false)
}
})
})

View File

@@ -0,0 +1,15 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../support/invariants"
}
]
}