Two had real security consequences: The bootstrap rejection ran on npm dotenv's parser while process.loadEnvFile applied the file with Node's own. Two independently maintained dialects meant the check and the thing it guards could disagree: a name Node accepts but the checker misses 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 with node:util's parseEnv — the same engine loadEnvFile uses — and assign the entries already checked, which also drops the dotenv dependency. llm-pi-ai still returned a literal profile.apiKey ahead of everything, and it registers a settings namespace, so the defect removed from llm-deepseek survived intact in its design twin. The field is gone from the profile schema, the resolution path, and the tests. The rest are consistency and documentation defects the review named: - verify-config-source-ownership did not scan the Python runtime's bundled cordis.yml, which still inlined apiKey and baseURL. Both are covered now, and the line-anchored INLINE_DENY documents that it is a tripwire, not a parser. - The deny list missed NODE_TLS_REJECT_UNAUTHORIZED, the askpass hooks, the GIT_CONFIG_* redirections, and PYTHONHOME — all implied by its own stated rule about what a variable does. - Snapshot lookups folded case on Windows, where environment names are case-insensitive and an exact-match Map could miss a higher-ranked layer. - The credentials note claimed a read-time permission check was "not taken" while this PR implemented it; the credentials-local README still described two layers, live process.env reads, dotenv-era limitations, and a renamed anchor; the llm-deepseek README still advertised the removed literal apiKey; and web.ts and base.cordis.yml kept personal-overlay wording. - The ownership note's literal-apiKey claim now names its scope: the web-search providers keep a literal field but register no settings namespace, so nothing can shadow a stored credential through them.
130 lines
6.7 KiB
TypeScript
130 lines
6.7 KiB
TypeScript
/**
|
|
* Gate: every user-facing value has one owner, and no shipped file smuggles a
|
|
* second one in.
|
|
*
|
|
* Two rules, both about the same failure — a value reaching the harness
|
|
* through a path nobody ranked:
|
|
*
|
|
* 1. Production package source does not read `process.env` directly. A
|
|
* credential belongs to `ctx.credentials`, a user-configurable value to the
|
|
* environment snapshot plus its owner's resolve step, and a real
|
|
* process-launch fact to the app bootstrap. Each remaining read is listed
|
|
* below with the reason it is one of those.
|
|
* 2. Shipped Cordis configuration does not inline a credential or an endpoint
|
|
* from the environment. Doing so re-creates the layer the snapshot exists
|
|
* to rank: `apiKey: !!js process.env.X` and `baseURL: !!js process.env.X`
|
|
* bypass both the credential seam and the endpoint ladder, and a project
|
|
* file could then decide where a key is sent.
|
|
* @module scripts/verify-config-source-ownership
|
|
*/
|
|
|
|
import { globSync, readFileSync } from 'node:fs'
|
|
import { resolve, sep } from 'node:path'
|
|
|
|
const ROOT = resolve(import.meta.dirname, '..')
|
|
|
|
/**
|
|
* Production package sources allowed to read `process.env`, each with the
|
|
* reason it is a process fact rather than a user-configurable value. Adding a
|
|
* row is a deliberate act: state which of the three owners it belongs to and
|
|
* why it cannot go there.
|
|
*/
|
|
const ENV_READ_ALLOWLIST: Readonly<Record<string, string>> = {
|
|
// The environment plane itself.
|
|
'packages/util/environment/src/index.ts': 'defines the snapshot; the inherited environment is its input',
|
|
'packages/ui/app-boot/src/index.ts': 'the app bootstrap that builds the snapshot and reads $DSH_SNAPSHOT',
|
|
'packages/util/paths/src/index.ts': 'resolves $DSH_HOME before any snapshot exists',
|
|
|
|
// Process-launch facts owned by the boundary that spawns or is spawned.
|
|
'packages/subprocess/subprocess/src/index.ts': 'scrubs the parent environment for children',
|
|
'packages/workflow/workflow-workerthread/src/host.ts': 'passes the parent environment to a worker thread',
|
|
'packages/ui/tui/src/index.ts': 'reads $COLORTERM, a terminal capability of this process',
|
|
'packages/lsp/lsp-local/src/index.ts': 'passes the parent environment to a language server it spawns',
|
|
'packages/cordis/repository-plugin/src/index.ts': 'resolves an MCP manifest against the spawning environment',
|
|
|
|
// Bootstrap-only DSH_* switches, which no discovered file may set.
|
|
'packages/skill/skill-local/src/index.ts': 'reads $DSH_AGENTS_HOME and $DSH_BUNDLED_SKILL_DIR, both bootstrap-only',
|
|
'packages/web/web/src/index.ts': 'reads $DSH_WEB_SEARCH_PROVIDER and $DSH_WEB_FETCH_PROVIDER, both bootstrap-only',
|
|
'packages/host/directory-picker-auto/src/index.ts': 'reads launch facts (display, SSH) of this process',
|
|
'packages/host/directory-picker-auto/src/resolve.ts': 'reads launch facts (display, SSH) of this process',
|
|
|
|
// Telemetry identity and consent, resolved once per process at bootstrap.
|
|
'packages/telemetry/session-telemetry-otel/src/user-id.ts': 'derives a machine identity from process facts',
|
|
'packages/sdk/telemetry/src/consent-resolver.ts': 'reads the SDK bootstrap consent switch',
|
|
'packages/sdk/telemetry/src/anonymous-id.ts': 'derives a machine identity from process facts',
|
|
|
|
// SDK and example bins: their own app bootstrap, outside the product CLI.
|
|
'packages/sdk/sdk-client/src/client.ts': 'SDK host bootstrap',
|
|
'packages/sdk/helper/src/features/builtin/provider.ts': 'SDK scaffolding reads the developer environment',
|
|
'packages/sdk/helper/src/features/builtin/app.ts': 'SDK scaffolding reads the developer environment',
|
|
'packages/sdk/helper/src/package-managers/package-manager.ts': 'detects the invoking package manager',
|
|
'packages/sdk/create-sdk/src/create-wizard.ts': 'SDK scaffolding reads the developer environment',
|
|
'packages/examples/jsonrpc-demo/src/bin.ts': 'demo bin bootstrap',
|
|
'packages/examples/acp-demo/src/bin.ts': 'demo bin bootstrap',
|
|
|
|
// Test and replay infrastructure.
|
|
'packages/support/loader-smoke/src/index.ts': 'test launcher composing a child environment',
|
|
'packages/support/llm-replay/src/index.ts': 'replay fixture switch',
|
|
'packages/support/acp-snapshot/src/launcher.ts': 'snapshot launcher composing a child environment',
|
|
|
|
// Browser bundle: `process.env` is replaced at build time, never read at runtime.
|
|
'packages/client/runtime/src/client/contract/store.ts': 'build-time constant folded by the bundler',
|
|
}
|
|
|
|
/** Shipped Cordis configuration these rules apply to. */
|
|
const SHIPPED_CONFIG_GLOBS = [
|
|
'apps/*/config/*.yml',
|
|
'examples/*/*.cordis.yml',
|
|
'examples/*/cordis.yml',
|
|
// The Python runtime ships its own default composition inside the wheel.
|
|
'python/*/src/**/cordis.yml',
|
|
]
|
|
|
|
/**
|
|
* Config keys that must never be inlined from the environment. Line-anchored
|
|
* on purpose: this is a tripwire for the shape people actually write, not a
|
|
* YAML analysis. A folded scalar or a block-literal spelling would slip past
|
|
* it, which is acceptable because the rule it guards is also stated in the
|
|
* owning Agent Note and enforced by the adapters' own resolution.
|
|
*/
|
|
const INLINE_DENY = /^\s*(apiKey|baseURL|apiKeyEnv|authToken|headers)\s*:\s*!!js\b/
|
|
|
|
const failures: string[] = []
|
|
|
|
for (const file of globSync('packages/*/*/src/**/*.ts', { cwd: ROOT })) {
|
|
const rel = file.split(sep).join('/')
|
|
if (!readFileSync(resolve(ROOT, rel), 'utf8').includes('process.env')) continue
|
|
if (rel in ENV_READ_ALLOWLIST) continue
|
|
failures.push(
|
|
`${rel}: reads process.env directly. A credential belongs to ctx.credentials, a user-configurable`
|
|
+ ' value to environmentOf(ctx) plus its owner\'s resolve step, and a process-launch fact to the app'
|
|
+ ' bootstrap. If it is genuinely one of those, add it to ENV_READ_ALLOWLIST with the reason.',
|
|
)
|
|
}
|
|
|
|
for (const glob of SHIPPED_CONFIG_GLOBS) {
|
|
for (const file of globSync(glob, { cwd: ROOT })) {
|
|
const rel = file.split(sep).join('/')
|
|
readFileSync(resolve(ROOT, rel), 'utf8').split('\n').forEach((line, index) => {
|
|
if (!INLINE_DENY.test(line)) return
|
|
failures.push(
|
|
`${rel}:${String(index + 1)}: inlines a credential or endpoint from the environment.`
|
|
+ ' The adapter resolves apiKeyEnv through ctx.credentials and the endpoint through the'
|
|
+ ' environment snapshot; inlining here bypasses both ladders.',
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
if (failures.length > 0) {
|
|
process.stderr.write('verify-config-source-ownership: configuration source ownership violated:\n')
|
|
for (const failure of failures) process.stderr.write(` ${failure}\n`)
|
|
process.exit(1)
|
|
}
|
|
|
|
const allowed = Object.keys(ENV_READ_ALLOWLIST).length
|
|
process.stdout.write(
|
|
`verify-config-source-ownership: no unregistered process.env reads (${String(allowed)} allowlisted)`
|
|
+ ' and no credential or endpoint inlined in shipped configuration.\n',
|
|
)
|