fix(scope): close remaining ownership boundaries

This commit is contained in:
Tianyi Cui
2026-07-12 03:51:55 +08:00
parent 3dca90261c
commit 36b8370027
79 changed files with 3957 additions and 817 deletions

View File

@@ -14,10 +14,10 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool-
### Public API
- `ctx.systemPrompt.section(section: PromptSection): () => Promise<void> | void` Contribute a section. The registry snapshots `name`, `order`, and the text value/callback, so later caller-object mutation cannot rename a stored section. The layer is the CALLING context's scope: `agent.ctx` contributes to that agent alone, SHADOWING a same-named global section there (the per-agent persona mechanism — a scoped `deployment:persona`). Duplicate names within one layer throw, and a globally protected section name cannot be shadowed. Disposed with the calling fiber.
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise<void> | void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set for `context.scope`; `knownNames` (defaulting to the schemas' names) is the pre-restriction universe `toolOrder` validates against. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber.
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise<void> | void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set for `context.scope`; `knownNames` (defaulting to the same captured schemas' names) is the pre-restriction universe `toolOrder` validates against. Assembly reads the result, each schema field, and the optional known-name list once before detaching them, rejects non-string schema names/descriptions or known names, and uses those same accepted strings for validation and the model-visible collection. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber.
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => Promise<void> | void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables (via `agent.ctx`) shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber.
- `ctx.systemPrompt.protect(protection: PromptProtection): () => Promise<void> | void` Make named section/tool contributions authoritative after the assembly waterfall. Protection restores canonical registry/provider presence and definition; restored entries keep canonical order with one another and anchor before their first surviving later unprotected canonical neighbor (or at the end), without undoing listener reordering of unprotected entries. Canonical absence is authoritative too, so a mode-hidden tool cannot be fabricated by a listener. Calling through `agent.ctx` protects only that agent's assemblies. A global section protection additionally reserves its name against scoped shadows; registering either side of that conflict fails loudly instead of treating the shadow as canonical. Inputs are snapshotted, empty protections throw, and disposal removes the protection.
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer (scoped shadows global). Runs through the scope-filtered `system-prompt/assemble` waterfall, then restores protected contributions from the pre-waterfall canonical assembly. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe (a restricted-away KNOWN tool is a normal absence), or when a provider returns the reserved rest-entry name.
- `ctx.systemPrompt.protect(protection: PromptProtection): () => Promise<void> | void` Make named section/tool contributions authoritative after the assembly waterfall. Protection restores canonical registry/provider presence and definition; restored entries keep canonical order with one another and anchor before their first surviving later unprotected canonical neighbor (or at the end), without undoing listener reordering of unprotected entries. Canonical absence is authoritative too, so a mode-hidden tool cannot be fabricated by a listener. Calling through `agent.ctx` protects only that agent's assemblies. A global section protection additionally reserves its name against scoped shadows; registering either side of that conflict fails loudly instead of treating the shadow as canonical. Each input array is read once and snapshotted, empty protections throw, and disposal removes the protection.
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer (scoped shadows global). Provider output becomes one coherent detached snapshot before `toolOrder` validation. Runs through the scope-filtered `system-prompt/assemble` waterfall, then restores protected contributions from the pre-waterfall canonical assembly. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe (a restricted-away KNOWN tool is a normal absence), or when a provider returns the reserved rest-entry name.
### Live events

View File

@@ -24,6 +24,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
@@ -32,6 +33,7 @@
"devDependencies": {
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -18,6 +18,7 @@ import z from 'schemastery'
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
declare module 'cordis' {
interface Context {
@@ -598,8 +599,9 @@ export class SystemPrompt extends Service {
* restored AFTER the whole waterfall, so listener registration order cannot
* strip, replace, duplicate, or fabricate it. Canonical absence is restored
* too: if the protected name is intentionally absent for an assembly, a
* listener-injected entry with that name is removed. The input arrays are
* snapshotted; an empty protection throws because it cannot affect output.
* listener-injected entry with that name is removed. Each input array is
* read once and snapshotted; an empty protection throws because it cannot
* affect output.
* Removed with the calling fiber and emits `system-prompt/change` on
* registration/unregistration. A global section protection also reserves the
* name against scoped section shadows; registering protection when such a
@@ -609,9 +611,11 @@ export class SystemPrompt extends Service {
*/
protect(protection: PromptProtection): () => Promise<void> | void {
const scope = scopeOf(this.ctx)
const sections = protection.sections
const tools = protection.tools
const snapshot: PromptProtection = {
...protection.sections !== undefined ? { sections: [...new Set(protection.sections)] } : {},
...protection.tools !== undefined ? { tools: [...new Set(protection.tools)] } : {},
...sections !== undefined ? { sections: [...new Set(sections)] } : {},
...tools !== undefined ? { tools: [...new Set(tools)] } : {},
}
if ((snapshot.sections?.length ?? 0) === 0 && (snapshot.tools?.length ?? 0) === 0) {
throw new Error('systemPrompt.protect() requires at least one section or tool name')
@@ -669,6 +673,8 @@ export class SystemPrompt extends Service {
* the providers' `knownNames` universe rejects the assembly, while a known
* name restricted away for this scope is a normal absence), and every
* visible variable resolved against `context` into `assembly.variables`.
* Each provider result and schema field is read once; those same captured
* names drive both `toolOrder` validation and the model-visible collection.
* Tool schemas are deep-cloned because adapters and request waterfalls may
* mutate schema objects. Runs through the `system-prompt/assemble`
* waterfall, giving listeners the opportunity to mutate or replace the
@@ -724,12 +730,43 @@ export class SystemPrompt extends Service {
const knownNames = new Set<string>()
for (const provider of providers) {
const result = provider(context)
for (const tool of result.schemas) {
collected.push({ ...tool, parameters: structuredClone(tool.parameters) })
}
for (const name of result.knownNames ?? result.schemas.map(tool => tool.name)) {
knownNames.add(name)
// One provider result snapshot: `schemas`, `knownNames`, and each schema
// field may be accessor-backed. The same captured names must drive both
// toolOrder validation and the model-visible collection.
const inputSchemas = result.schemas
const inputKnownNames = result.knownNames
const schemas = inputSchemas.map((tool, index): ToolSchema => {
const name = tool.name
const description = tool.description
const inputParameters = tool.parameters
if (typeof name !== 'string') {
throw new TypeError(`system prompt tool schema at index ${index} name must be a string`)
}
if (typeof description !== 'string') {
throw new TypeError(`system prompt tool "${name}" description must be a string`)
}
const parameters = snapshotJsonValue(inputParameters)
if (parameters === undefined) {
throw new TypeError(`system prompt tool "${name}" parameters must be losslessly JSON-serializable`)
}
return { name, description, parameters }
})
let acceptedKnownNames: string[]
if (inputKnownNames === undefined) {
acceptedKnownNames = schemas.map(tool => tool.name)
} else {
if (!Array.isArray(inputKnownNames)) {
throw new TypeError('system prompt tool provider knownNames must be an array of strings')
}
acceptedKnownNames = Array.from(inputKnownNames, (name) => {
if (typeof name !== 'string') {
throw new TypeError('system prompt tool provider knownNames must be an array of strings')
}
return name
})
}
collected.push(...schemas)
for (const name of acceptedKnownNames) knownNames.add(name)
}
const assembly: PromptAssembly = {
sections: [...sectionByName.values()]

View File

@@ -258,6 +258,30 @@ describe('SystemPrompt', () => {
expect(assembly.tools.map(tool => tool.name)).toEqual(['alpha', 'protected', 'zulu'])
})
it('reads protection accessors once so the checked names are the protected names', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
ctx.systemPrompt.section({ name: 'protected', order: 10, text: 'canonical' })
let reads = 0
const protection = {
get sections(): string[] {
reads += 1
return reads === 1 ? ['protected'] : undefined as unknown as string[]
},
}
ctx.systemPrompt.protect(protection)
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const result = await next()
result.sections = result.sections.filter(section => section.name !== 'protected')
return result
})
const assembly = await ctx.systemPrompt.assemble()
expect(reads).toBe(1)
expect(assembly.sections).toContainEqual({ name: 'protected', order: 10, text: 'canonical' })
})
it('protects canonical absence and rejects an empty protection', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)

View File

@@ -48,6 +48,100 @@ describe('SystemPrompt tool order', () => {
expect(names(await ctx.systemPrompt.assemble())).toEqual(['todo_write', 'echo_a', 'echo_b', 'bash'])
})
it('reads provider schemas once so toolOrder validates the model-visible collection', async () => {
const ctx = await mount({ toolOrder: ['actual', TOOL_ORDER_REST] })
let reads = 0
ctx.systemPrompt.tools(() => ({
get schemas(): ToolSchema[] {
reads += 1
return reads === 1 ? [tool('actual')] : [tool('phantom')]
},
}))
const assembly = await ctx.systemPrompt.assemble()
expect(reads).toBe(1)
expect(names(assembly)).toEqual(['actual'])
})
it('reads each provider schema field once before detaching it', async () => {
const ctx = await mount()
const accepted = { type: 'object', properties: { accepted: { type: 'string' } } }
let reads = 0
const schema = {
name: 'stable',
description: 'stable',
get parameters(): object {
reads += 1
return reads === 1 ? accepted : { type: 'object', properties: { drifted: { type: 'number' } } }
},
} as ToolSchema
ctx.systemPrompt.tools(() => ({ schemas: [schema] }))
const assembly = await ctx.systemPrompt.assemble()
expect(reads).toBe(1)
expect(assembly.tools[0]?.parameters).toEqual(accepted)
})
it('rejects exotic provider parameters before model-visible assembly', async () => {
const ctx = await mount()
class ExoticParameters {
readonly type = 'object'
readonly properties = { value: { type: 'string' } }
}
ctx.systemPrompt.tools(() => ({
schemas: [{
name: 'exotic',
description: 'must not be sanitized',
parameters: new ExoticParameters() as unknown as ToolSchema['parameters'],
}],
}))
await expect(ctx.systemPrompt.assemble())
.rejects.toThrow(/parameters must be losslessly JSON-serializable/)
})
it('rejects malformed fixed provider fields without freezing caller objects', async () => {
const ctx = await mount()
const badName = { value: 'object-name' }
const badDescription = { value: 'object-description' }
ctx.systemPrompt.tools(() => ({
schemas: [{
name: badName as unknown as string,
description: 'bad name',
parameters: {},
}],
}))
await expect(ctx.systemPrompt.assemble()).rejects.toThrow('name must be a string')
expect(Object.isFrozen(badName)).toBe(false)
const descriptions = await mount()
descriptions.systemPrompt.tools(() => ({
schemas: [{
name: 'bad-description',
description: badDescription as unknown as string,
parameters: {},
}],
}))
await expect(descriptions.systemPrompt.assemble()).rejects.toThrow('description must be a string')
expect(Object.isFrozen(badDescription)).toBe(false)
const knownNames = await mount()
knownNames.systemPrompt.tools(() => ({
schemas: [tool('valid')],
knownNames: [{} as unknown as string],
}))
await expect(knownNames.systemPrompt.assemble()).rejects.toThrow('knownNames must be an array of strings')
const nonArrayKnownNames = await mount()
nonArrayKnownNames.systemPrompt.tools(() => ({
schemas: [tool('valid')],
knownNames: 'valid' as unknown as string[],
}))
await expect(nonArrayKnownNames.systemPrompt.assemble()).rejects.toThrow('knownNames must be an array of strings')
})
it('rejects the assembly when toolOrder names a tool that is not registered (misconfiguration blocks work)', async () => {
const ctx = await mount({ toolOrder: ['todo_write', 'ghost', TOOL_ORDER_REST, 'wraith'] })
ctx.systemPrompt.tools(() => ({ schemas: [tool('bash'), tool('todo_write')] }))

View File

@@ -22,6 +22,9 @@
},
{
"path": "../../core/scope"
},
{
"path": "../../core/session"
}
]
}