cleanup(config): consolidate source ownership rationale

This commit is contained in:
Tianyi Cui
2026-08-07 23:43:15 +08:00
parent b5fbcaccf8
commit 62ae990c27
7 changed files with 30 additions and 134 deletions
+12 -75
View File
@@ -94,17 +94,12 @@ const BOOTSTRAP_NAMES = new Set([
'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.
// Interpreter startup hooks.
'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).
// Version-control command hooks and config redirects.
'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',
@@ -113,8 +108,6 @@ const BOOTSTRAP_NAMES = new Set([
'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',
])
@@ -122,30 +115,8 @@ const BOOTSTRAP_NAMES = new 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.
* Whether a variable may come only from the inherited process environment
* because it changes process, runtime, VCS, or network bootstrap.
* @param name - the variable name.
* @returns true when only the inherited environment may supply it.
*/
@@ -155,12 +126,8 @@ function isBootstrapOnly(name: string): boolean {
}
/**
* Parse one directory's `.env` without applying it, rejecting any bootstrap
* variable it declares. A discovered file must not decide how this process
* launches, where its code and model-visible instructions come from, or how it
* reaches the network, so a violation fails the launch BEFORE anything is
* materialized — reporting it afterwards would leave the process already
* running under the value it refused.
* Parse one directory's `.env` without applying it, rejecting bootstrap-only
* names before any value is materialized.
* @param binName - the diagnostic prefix on the thrown error.
* @param dir - the directory whose `.env` to read.
* @param warn - sink for the one-line unreadable-file diagnostic.
@@ -181,12 +148,7 @@ function readEnvLayer(
// ENOENT (no .env) is fine — rely on the ambient environment.
return undefined
}
// `node:util`'s parseEnv is the same parser `--env-file` and
// `process.loadEnvFile` use. Checking with a second dialect (npm dotenv)
// would leave the rejection rule and the thing it guards on independently
// maintained parsers: a name Node accepts but the checker does not would
// reach `process.env` unchecked, and `BASH_ENV` there runs a file of the
// project's choosing on every `bash -c` the bash tool issues.
// Parse once so validation and materialization use exactly the same entries.
const values = parseEnv(content) as Record<string, string>
for (const name of Object.keys(values)) {
if (!isBootstrapOnly(name)) continue
@@ -200,30 +162,10 @@ function readEnvLayer(
}
/**
* Load the dsh product CLI's user environment and return it as a snapshot that
* remembers which layer supplied each value: the invoking directory's `.env`
* over the Harness home's `.env`, both under the inherited process
* environment.
*
* Each layer is parsed once, checked, and only then applied — never replacing
* a name already set, which is what makes the layering `user < project <
* inherited`. The single parse is deliberate: the rejection rule and the
* values that reach `process.env` must come from the same parser, or a name
* one dialect accepts and the other misses would slip past the check. Values do reach
* `process.env`, because a user's own `--config` tree and third-party
* libraries read it; the returned snapshot is the authority for everything the
* harness itself resolves, since `process.env` alone cannot say whether a
* value came from the launching shell or from a file inside the workspace.
*
* The Harness home is resolved from the inherited environment *before* either
* file loads, so a project `.env` can never redirect which user document is
* read. Only the product CLI layers these files: an SDK or example bin loads
* its own directory through {@link loadEnv} and must not inherit a developer's
* `$DSH_HOME`.
*
* These are ordinary environment values with ordinary environment reach. A
* secret the Harness should own and isolate belongs in the credentials
* document, which is never materialized here.
* Load the product CLI's inherited > invoking-directory `.env` > Harness-home
* `.env` snapshot. The Harness home resolves before either file; both files
* are checked before either is applied, and accepted values are materialized
* without replacing inherited ones. The snapshot preserves source provenance.
* @param binName - the diagnostic prefix on the diagnostics.
* @param cwd - the invoking directory whose `.env` is the project layer.
* @param warn - sink for the one-line misconfiguration diagnostics.
@@ -239,12 +181,7 @@ export function loadLayeredEnv(
// Parse both layers first: a rejection must not leave one file applied.
const project = readEnvLayer(binName, cwd, warn)
const user = home === resolve(cwd) ? undefined : readEnvLayer(binName, home, warn)
// Assign the entries this function already parsed and checked, rather than
// re-reading each file through `process.loadEnvFile`. One parse means the
// snapshot, the rejection rule, and `process.env` can never disagree about
// what a file contains. Skipping names already set reproduces the
// never-replace behavior that makes the layering `user < project <
// inherited`.
// Apply the checked values without replacing a higher-ranked name.
for (const layer of [project, user]) {
if (layer === undefined) continue
for (const [name, value] of Object.entries(layer.values)) {