fix(config): trust the invoking project, and stop leaking what it must not decide

Review found five real defects in the configuration-source work, all confirmed
against the code rather than argued:

1. The note claimed --config outranks settings.yaml. It does not: the settings
   seam registers a plugin's cordis entry config as the `base` layer and the
   user section layers over it, and the seam cannot tell a shipped value from a
   --config one. The note now states shipped reality and names --config-replace
   as the lever for a deployment that must win. Separately, a literal `apiKey`
   in settings outranked both the environment and .credentials.yaml — the field
   is removed, so configuration carries a reference and nothing else.
2. DEEPSEEK_SEARCH_BASE_URL was functionally deleted: the shipped inline went
   away without the provider learning to read it. It now resolves from the
   environment snapshot, as the README always claimed.
3. The bootstrap deny list missed the interpreter start-up hooks. BASH_ENV is
   the sharpest: `bash -c` sources it on every bash tool call, so a project
   .env could run a file of its choosing before every command. The list now
   covers BASH_ENV and its per-language siblings, the Git hook commands, and
   the remaining preload and CA variables, organised by what a variable does
   rather than which runtime owns it.
4. YAML parse errors quoted the offending source line — which in a credentials
   document is the secret — into boot stderr and the watcher's logger. Only the
   error code and position are reported now, in credentials-local and
   settings-local alike, pinned by a test that asserts the secret is absent.
5. 0600 governed only files the harness wrote. A hand-created 0644 document was
   read normally. POSIX now checks the mode before reading contents, at boot
   and on every reload; Windows has no mode to inspect and is skipped rather
   than faked.

The project a session is launched in is trusted by default, with no prompt and
no stored trust record: it may supply its own endpoint, ordinary variables, and
a key ranked below the managed store. Trust stops at the harness itself — a
discovered file still cannot set DSH_PERMISSION_MODE, PATH, BASH_ENV, or the
rest, because those take effect with no user action, before any turn, outside
the permission policy and the sandbox.
This commit is contained in:
Yichen Jiang
2026-08-04 17:16:11 +08:00
parent 0512b12714
commit 8c2970e70e
31 changed files with 366 additions and 207 deletions
@@ -61,7 +61,7 @@ describe('request-level dynamic configuration', () => {
it('routes the next request with the freshly resolved base URL and credential', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const dir = await home()
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: first-key\n')
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: first-key\n', { mode: 0o600 })
const serverA = await mockServer([{ kind: 'sse', events: textEvents }])
const serverB = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx } = await boot(dir, { baseURL: serverA.url })
@@ -78,16 +78,21 @@ describe('request-level dynamic configuration', () => {
expect(serverB.headers[0]?.authorization).toBe('Bearer second-key')
})
it('prefers a literal settings apiKey over the credential layers', async () => {
it('refuses a literal apiKey in settings and keeps serving the stored credential', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const dir = await home()
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: file-key\n')
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: file-key\n', { mode: 0o600 })
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx } = await boot(dir, { baseURL: server.url })
// Configuration carries a reference, never a value. The namespace has no
// `apiKey` field, so writing one is dropped by the schema rather than
// rejected (no adapter namespace is strict); what matters is that a
// settings document cannot become a second credential store outranking
// `.credentials.yaml` and the environment.
await ctx.settings.update(NS, { apiKey: 'literal-key' })
await prompt(ctx)
expect(server.headers[0]?.authorization).toBe('Bearer literal-key')
expect(server.headers[0]?.authorization).toBe('Bearer file-key')
})
it('starts keyless and serves the next request once the key arrives', async () => {
@@ -152,17 +157,16 @@ describe('request-level dynamic configuration', () => {
])
})
it('sends the whole last-good snapshot when a rejected one changed both the key and the URL', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
it('keeps the whole last-good snapshot when a rejected one changed the URL', async () => {
const dir = await home()
const good = await mockServer([{ kind: 'sse', events: textEvents }])
const rejected = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx } = await boot(dir, { apiKey: 'good-key', baseURL: good.url })
vi.stubEnv('DEEPSEEK_API_KEY', 'good-key')
const { ctx } = await boot(dir, { baseURL: good.url })
// One snapshot moves the endpoint AND the literal key, and fails the
// resolve step beyond the schema (duplicate catalog ids).
// One snapshot moves the endpoint and fails the resolve step beyond the
// schema (duplicate catalog ids).
await ctx.settings.update(NS, {
apiKey: 'rejected-key',
baseURL: rejected.url,
models: [{ id: 'dup' }, { id: 'dup' }],
})
@@ -178,7 +182,7 @@ describe('request-level dynamic configuration', () => {
it('falls back to the composition entry when settings detach', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const dir = await home()
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: steady-key\n')
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: steady-key\n', { mode: 0o600 })
const serverA = await mockServer([{ kind: 'sse', events: textEvents }])
const serverB = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx, settingsFiber } = await boot(dir, { baseURL: serverA.url })