cleanup(config): localize bootstrap policy to app boot
This commit is contained in:
@@ -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/util/environment/README.md
|
||||
README.md: 1df857851f0f0a5a5ac52365c5e563a1c001bfca
|
||||
README.zh.md: 41ea1af3a6eb95d58456f43e0f7f0a90e4fe7ac6
|
||||
README.md: af6b0d9cc66b0bdfa1ad9ffb273260d0f4f06ddc
|
||||
README.zh.md: 98c3c69ec96f835721e042960fe044fe075e6159
|
||||
@@ -30,17 +30,7 @@ const endpoint = environmentOf(ctx).get('DEEPSEEK_BASE_URL')?.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.
|
||||
|
||||
Trusting a project to configure the agent's work is not the same as letting it change the harness. A bootstrap variable decides **how a process launches** (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`, `DYLD_*`), **what code a runtime executes before the program it was asked to run** (`BASH_ENV` and its per-language siblings — `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS` — plus the Git hook commands), **where model-visible instructions load from** (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or **how the network is reached and trusted** (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass.
|
||||
|
||||
These take effect with no user action, before any turn, outside the permission policy and the sandbox: `DSH_PERMISSION_MODE` would switch off the approvals that make trusting a project meaningful, and `BASH_ENV` runs a file of the project's choosing on every `bash -c` the bash tool issues.
|
||||
|
||||
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 reach child processes under [`dsh-subprocess`](../../subprocess/subprocess/README.md)'s scrub. That is intended for ordinary variables; the code-loading hooks that would abuse it are rejected at load instead, and the deny list is the thing to extend when a new runtime hook appears.
|
||||
- **The snapshot is not a subprocess boundary** — every layer is also materialized into `process.env`, so ordinary project variables reach child processes under [`dsh-subprocess`](../../subprocess/subprocess/README.md)'s scrub. The product launcher's [`.env` contract](../../ui/app-boot/README.md#profiles) rejects bootstrap variables before materialization.
|
||||
- **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.
|
||||
@@ -30,17 +30,7 @@ const endpoint = environmentOf(ctx).get('DEEPSEEK_BASE_URL')?.value
|
||||
|
||||
当产品 CLI(命令行界面)启动了这棵树时,`environmentOf(ctx)` 返回启动器的快照;否则返回只含继承环境的那一层。该回退并不削弱规则:SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,因此它拥有的一切确实就是它被启动时的环境。
|
||||
|
||||
## bootstrap 变量
|
||||
|
||||
`isBootstrapOnly(name)` 给出只有继承环境才能设置的变量。启动器一旦发现某个 `.env` 声明了其中之一,就会在应用任何内容之前拒绝启动。
|
||||
|
||||
信任一个项目配置 agent 的工作,不等于让它改变 harness 本身。bootstrap 变量决定**进程如何启动**(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`、`DYLD_*`)、**运行时在执行被要求运行的程序之前先执行哪些代码**(`BASH_ENV` 及其各语言同类——`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`——以及 Git 的钩子命令)、**模型可见的指令从哪里加载**(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),或者**网络如何抵达与信任**(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。
|
||||
|
||||
这些变量无需任何用户动作、在任何一轮开始之前、且在权限策略与沙箱之外就生效:`DSH_PERMISSION_MODE` 会关掉让「信任项目」有意义的那道审批,而 `BASH_ENV` 会在 bash 工具发出的每一次 `bash -c` 上执行项目指定的文件。
|
||||
|
||||
整个 `DSH_*` 命名空间被拒绝,而不是只拒绝一份经过审查的子集:harness 自己的开关——权限模式、agents home、内置 skill(技能)根目录——恰恰是敌意项目最想要的,而后来新增的开关不能因为忘记登记就变得可设置。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **快照不是子进程边界**:每一层同样会被物化进 `process.env`,因此项目里的普通变量会按 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 的清洗规则抵达子进程。这对普通变量是有意为之;会滥用这一点的代码加载钩子改为在加载时拒绝,新的运行时钩子出现时该扩展的是那份拒绝清单。
|
||||
- **快照不是子进程边界**:每一层同样会被物化进 `process.env`,因此项目里的普通变量会按 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 的清洗规则抵达子进程。产品启动器的 [`.env` 契约](../../ui/app-boot/README.md#profiles) 会在物化之前拒绝 bootstrap 变量。
|
||||
- **没有按工作区划分的层**:项目层是*调用*目录,在启动时固定。之后在 Web UI 中选择的工作区不贡献任何内容,这是刻意的:跟随它等于让模型自己的工作区在会话中途改变 harness 的环境。
|
||||
@@ -143,69 +143,3 @@ declare module 'cordis' {
|
||||
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', 'LD_AUDIT',
|
||||
// Interpreter start-up hooks: each of these makes a runtime execute a file
|
||||
// of the setter's choosing on every invocation, before the program runs.
|
||||
// `BASH_ENV` is the sharpest — the bash tool spawns `bash -c`, which sources
|
||||
// it every time — but every runtime an agent shells out to has one.
|
||||
'BASH_ENV', 'ENV', 'SHELLOPTS', 'BASHOPTS',
|
||||
'PERL5OPT', 'PERL5LIB', 'PYTHONSTARTUP', 'PYTHONPATH', 'RUBYOPT', 'RUBYLIB',
|
||||
'JAVA_TOOL_OPTIONS', '_JAVA_OPTIONS', 'JDK_JAVA_OPTIONS',
|
||||
'PYTHONHOME',
|
||||
// Version-control hooks that run a command on the setter's behalf, and the
|
||||
// config redirections that can define such a hook indirectly (a substituted
|
||||
// git config file can set core.pager or a credential helper).
|
||||
'GIT_SSH', 'GIT_SSH_COMMAND', 'GIT_EXTERNAL_DIFF', 'GIT_PAGER', 'GIT_EDITOR',
|
||||
'GIT_ASKPASS', 'SSH_ASKPASS',
|
||||
'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CONFIG_COUNT',
|
||||
'EDITOR', 'VISUAL', 'PAGER',
|
||||
// Network reach and trust.
|
||||
'SSL_CERT_FILE', 'SSL_CERT_DIR',
|
||||
'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
|
||||
'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE',
|
||||
// Turns off TLS verification outright, which is the sharpest form of
|
||||
// "how the network is trusted".
|
||||
'NODE_TLS_REJECT_UNAUTHORIZED',
|
||||
])
|
||||
|
||||
/** Name prefixes no discovered file may set. */
|
||||
const BOOTSTRAP_PREFIXES = ['DSH_', 'XDG_', 'DYLD_', 'BASH_FUNC_']
|
||||
|
||||
/**
|
||||
* Whether a variable may come only from the inherited process environment.
|
||||
*
|
||||
* The invoking project is trusted to *configure* the agent's work — its
|
||||
* endpoints, its ordinary variables, even a credential. It is not trusted to
|
||||
* change the harness itself, and that is what a bootstrap variable does: it
|
||||
* decides how a process launches (`PATH`, `NODE_OPTIONS`, `LD_PRELOAD`), what
|
||||
* code a runtime executes before the program it was asked to run (`BASH_ENV`
|
||||
* and its per-language siblings, the Git hook commands), where 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).
|
||||
*
|
||||
* The distinction is that these take effect with no user action, before any
|
||||
* turn, outside the permission policy and the sandbox — `DSH_PERMISSION_MODE`
|
||||
* would switch off the approvals that make trusting a project meaningful at
|
||||
* all, and `BASH_ENV` runs a file of the project's choosing on every single
|
||||
* `bash -c` the tool issues. Trusting a project's code to run under the
|
||||
* agent's policy is not the same as letting it rewrite that policy.
|
||||
*
|
||||
* They are therefore rejected at load rather than ranked below another layer:
|
||||
* a user who wrote one into a file believes it applies, and silently ignoring
|
||||
* it is its own failure. The whole `DSH_*` namespace is denied rather than an
|
||||
* audited subset, because a switch added later must not become settable by
|
||||
* being forgotten.
|
||||
* @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))
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import {
|
||||
createEnvironmentSnapshot, DSH_ENVIRONMENT_KEY, environmentOf, isBootstrapOnly,
|
||||
createEnvironmentSnapshot, DSH_ENVIRONMENT_KEY, environmentOf,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const layered = createEnvironmentSnapshot([
|
||||
@@ -67,37 +67,3 @@ describe('environmentOf', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user