fix(core): enforce agent-scoped ownership boundaries

This commit is contained in:
Tianyi Cui
2026-07-11 22:55:26 +08:00
parent 850796bb35
commit 3263dab822
62 changed files with 3982 additions and 857 deletions

View File

@@ -1,8 +1,9 @@
/**
* System prompt assembly registry. Plugins contribute ordered text sections,
* tool schema providers, and named prompt variables; `assemble(context)`
* collates them through a waterfall that runs once per step, and
* `renderPrompt` interpolates `{{variable}}` references into the final text.
* tool schema providers, named prompt variables, and authoritative named
* protections; `assemble(context)` collates them through a waterfall that
* runs once per step, restores protected contributions, and `renderPrompt`
* interpolates `{{variable}}` references into the final text.
*
* The harness-owned prompt openers live here too: this plugin registers the
* static `harness:identity` section (order −100) and the deployment's
@@ -43,8 +44,8 @@ declare module 'cordis' {
*/
'system-prompt/assemble'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
/**
* A section, tool provider, or variable provider was registered or
* unregistered (the assembly inputs changed — possibly for one scope
* A section, tool provider, variable provider, or protection was registered
* or unregistered (the assembly inputs changed — possibly for one scope
* only). An UNFILTERED registry-subject notification, deliberately not
* scope-filtered dispatch: a global change concerns every agent's next
* assembly, so a scoped listener subscribing here sees every change, not
@@ -121,6 +122,27 @@ export interface ToolProviderResult {
knownNames?: readonly string[]
}
/**
* Canonical prompt contributions that survive the assembly waterfall.
*
* Protection is declarative by contribution name rather than an ordered
* callback: after every `system-prompt/assemble` listener has finished, the
* service restores each protected name to the exact presence and definition
* produced by its registries before the waterfall. Restored entries keep
* canonical order with one another and anchor before their first surviving
* later unprotected canonical neighbor (or at the end); the service does not
* undo a listener's reordering of unprotected entries. A name absent from that
* canonical assembly is removed from the result. This makes mode-dependent
* absence protectable too (for example, a native tool that intentionally stays
* off the wire in Code Mode).
*/
export interface PromptProtection {
/** Section names whose canonical registry output is authoritative. */
sections?: readonly string[]
/** Tool names whose canonical provider output is authoritative. */
tools?: readonly string[]
}
/**
* The assembled prompt.
*
@@ -211,6 +233,28 @@ function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined, knownN
name === TOOL_ORDER_REST ? rest : tools.filter(tool => tool.name === name))
}
/** Restore protected named entries from `canonical`, anchored before their next unprotected canonical neighbor. */
function restoreProtected<T extends { name: string }>(
canonical: readonly T[], result: readonly T[], protectedNames: ReadonlySet<string>,
): T[] {
const restored = result.filter(entry => !protectedNames.has(entry.name))
for (const [index, entry] of canonical.entries()) {
if (!protectedNames.has(entry.name)) continue
// Protected entries are inserted in canonical order. Anchor each one
// before the first later UNPROTECTED canonical neighbor that survived the
// waterfall; if none survived, it belongs at the end. Looking only at
// unprotected neighbors avoids reversing adjacent protected entries.
const following = new Set(
canonical.slice(index + 1)
.filter(candidate => !protectedNames.has(candidate.name))
.map(candidate => candidate.name),
)
const next = restored.findIndex(candidate => following.has(candidate.name))
restored.splice(next < 0 ? restored.length : next, 0, structuredClone(entry))
}
return restored
}
/** Lexicographic (code-unit) name comparison — locale-independent, so the order is identical on every machine. */
function compareToolNames(a: ToolSchema, b: ToolSchema): number {
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
@@ -327,10 +371,10 @@ function interpolate(section: AssembledSection, variables: Record<string, string
/**
* Registry service (`ctx.systemPrompt`): plugins contribute ordered text
* sections, tool-schema providers, and named prompt variables; the agent loop
* calls `assemble(context)` once per step. Registers the harness-owned
* `harness:identity` and `deployment:persona` sections itself (see
* {@link Config.persona}).
* sections, tool-schema providers, named prompt variables, and authoritative
* contribution protections; the agent loop calls `assemble(context)` once per
* step. Registers the harness-owned `harness:identity` and
* `deployment:persona` sections itself (see {@link Config.persona}).
*/
export class SystemPrompt extends Service {
static Config: z<Config> = z.object({
@@ -347,10 +391,12 @@ export class SystemPrompt extends Service {
private sections: PromptSection[] = []
private toolProviders: ((context: AssembleContext) => ToolProviderResult)[] = []
private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>()
private protections: PromptProtection[] = []
/** Per-scope layers (`@deepseek-ai/dsh-scope`); entries drop when a layer empties, so a disposed scope leaves no residue. */
private scopedSections = new Map<ScopeKey, PromptSection[]>()
private scopedToolProviders = new Map<ScopeKey, ((context: AssembleContext) => ToolProviderResult)[]>()
private scopedVariableProviders = new Map<ScopeKey, Map<string, (context: AssembleContext) => string | undefined>>()
private scopedProtections = new Map<ScopeKey, PromptProtection[]>()
private readonly toolOrder: string[] | undefined
constructor(ctx: Context, public config: Config) {
@@ -383,7 +429,12 @@ export class SystemPrompt extends Service {
* scoped context (`agent.ctx`) contributes to that scope alone — and a
* scoped section SHADOWS a same-named global section for that scope's
* assemblies (most-specific-wins; this is how a per-agent persona overrides
* `deployment:persona`). Throws if the SAME layer already has the name (a
* `deployment:persona`) unless that global name is protected: global
* protection reserves its section name against scoped shadows so the
* registration owner—not a later scope—defines the canonical value. The
* registry snapshots `name`, `order`, and `text` before checking/storing, so
* later caller-object mutation cannot rename a contribution. Throws
* if the SAME layer already has the name (a
* duplicate would silently double prompt text — e.g. a double-loaded tool
* plugin; the global-duplicate message names `agent.ctx` as the per-agent
* alternative). Removed when the calling fiber is disposed. Emits
@@ -395,6 +446,14 @@ export class SystemPrompt extends Service {
*/
section(section: PromptSection): () => Promise<void> | void {
const scope = scopeOf(this.ctx)
const snapshot: PromptSection = {
name: section.name,
order: section.order,
text: section.text,
}
if (scope !== undefined && this.protections.some(record => record.sections?.includes(snapshot.name))) {
throw new Error(`prompt section "${snapshot.name}" is globally protected and cannot be shadowed in an agent scope`)
}
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
const layer = scope === undefined
? this.sections
@@ -403,18 +462,18 @@ export class SystemPrompt extends Service {
this.scopedSections.set(scope, created)
return created
})()
if (layer.some(existing => existing.name === section.name)) {
if (layer.some(existing => existing.name === snapshot.name)) {
throw new Error(scope === undefined
? `prompt section "${section.name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
: `prompt section "${section.name}" is already registered in this scope`)
? `prompt section "${snapshot.name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
: `prompt section "${snapshot.name}" is already registered in this scope`)
}
layer.push(section)
layer.push(snapshot)
// Yield the rollback BEFORE emitting `system-prompt/change`: a generator
// effect collects each yielded disposer before the next step runs, so a
// throwing change listener removes the section instead of leaking it into
// every future assembly.
yield () => {
const index = layer.indexOf(section)
const index = layer.indexOf(snapshot)
/* v8 ignore next 3 -- defensive: section was registered, so indexOf is guaranteed >= 0 */
if (index >= 0) layer.splice(index, 1)
if (scope !== undefined && layer.length === 0) this.scopedSections.delete(scope)
@@ -531,6 +590,73 @@ export class SystemPrompt extends Service {
return dispose
}
/**
* Protect named section/tool contributions from the assembly waterfall.
* The layer is decided by the calling context: a global protection applies
* to every assembly, while one registered through `agent.ctx` applies only
* to that agent's scope. The name's canonical registry/provider output is
* 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.
* 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
* shadow already exists fails loudly instead of protecting the wrong owner.
* @param protection - section and/or tool names whose canonical presence and definitions are authoritative.
* @returns the exact Cordis effect disposer that removes the protection.
*/
protect(protection: PromptProtection): () => Promise<void> | void {
const scope = scopeOf(this.ctx)
const snapshot: PromptProtection = {
...protection.sections !== undefined ? { sections: [...new Set(protection.sections)] } : {},
...protection.tools !== undefined ? { tools: [...new Set(protection.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')
}
if (scope === undefined && snapshot.sections !== undefined) {
const protectedSections = new Set(snapshot.sections)
const conflicts = [...this.scopedSections.values()]
.flatMap(layer => layer.filter(section => protectedSections.has(section.name)).map(section => section.name))
if (conflicts.length > 0) {
throw new Error(`systemPrompt.protect() cannot globally protect section${conflicts.length > 1 ? 's' : ''} ${[...new Set(conflicts)].map(name => `"${name}"`).join(', ')} while scoped shadows are registered`)
}
}
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
const layer = scope === undefined
? this.protections
: this.scopedProtections.get(scope) ?? (() => {
const created: PromptProtection[] = []
this.scopedProtections.set(scope, created)
return created
})()
layer.push(snapshot)
yield () => {
const index = layer.indexOf(snapshot)
/* v8 ignore next 3 -- defensive: protection was registered, so indexOf is guaranteed >= 0 */
if (index >= 0) layer.splice(index, 1)
if (scope !== undefined && layer.length === 0) this.scopedProtections.delete(scope)
this.ctx.emit('system-prompt/change')
}
this.ctx.emit('system-prompt/change')
}.bind(this), 'systemPrompt.protect()')
return dispose
}
/** Resolve the authoritative names registered for one assembly scope. */
private protectedNames(scope: ScopeKey | undefined): { sections: Set<string>; tools: Set<string> } {
const records = [
...this.protections,
...(scope === undefined ? [] : this.scopedProtections.get(scope)) ?? [],
]
return {
sections: new Set(records.flatMap(record => record.sections ?? [])),
tools: new Set(records.flatMap(record => record.tools ?? [])),
}
}
/**
* Assemble the current prompt for one caller: the global layer merged with
* {@link AssembleContext.scope}'s layer (scoped sections/variables SHADOW
@@ -546,10 +672,11 @@ export class SystemPrompt extends Service {
* 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
* assembly before it reaches the model — like the sections' `order` sort,
* tool canonicalization happens on the initial assembly, and a listener
* owns the determinism of whatever it emits. Await the result before
* reading the assembly values — waterfall listeners may be async.
* assembly, then restores every visible {@link PromptProtection} from the
* pre-waterfall canonical assembly. Like the sections' `order` sort, tool
* canonicalization happens on the initial assembly; unprotected listener
* output owns its own determinism. Await the result before reading the
* assembly values — waterfall listeners may be async.
* Interpolation happens later, in {@link renderPrompt}.
* @param context - what this assembly is for (defaults to an empty context;
* see {@link AssembleContext}).
@@ -560,6 +687,10 @@ export class SystemPrompt extends Service {
// (`assemble().catch(...)` would miss it).
async assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
const scope = context.scope
// Protection is a registry input too: snapshot which names are protected
// at assembly start. Registrations that land while an async waterfall is
// in flight affect the NEXT assembly, matching the other registries.
const protectedNames = this.protectedNames(scope)
// Variables: global layer first, then the scope's layer OVERWRITES
// same-named entries (shadowing — a per-agent value wins for that agent).
const variables: Record<string, string | undefined> = {}
@@ -611,7 +742,27 @@ export class SystemPrompt extends Service {
tools: orderTools(collected, this.toolOrder, knownNames),
variables,
}
return this.ctx.waterfall(scopeTarget(this, scope), 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly))
// Snapshot only the fields protection can restore. The waterfall receives
// `assembly` by reference and may mutate it or return a replacement; these
// independent snapshots remain the authoritative registry product.
const canonicalSections = protectedNames.sections.size > 0 ? structuredClone(assembly.sections) : undefined
const canonicalTools = protectedNames.tools.size > 0 ? structuredClone(assembly.tools) : undefined
const result = await this.ctx.waterfall(
scopeTarget(this, scope), 'system-prompt/assemble', assembly, context,
() => Promise.resolve(assembly),
)
// Build a replacement instead of mutating the waterfall result: a
// listener may legitimately return a frozen assembly. Merge-extensible
// fields ride through the spread untouched.
return {
...result,
...canonicalSections !== undefined
? { sections: restoreProtected(canonicalSections, result.sections, protectedNames.sections) }
: {},
...canonicalTools !== undefined
? { tools: restoreProtected(canonicalTools, result.tools, protectedNames.tools) }
: {},
}
}
}

View File

@@ -62,6 +62,21 @@ describe('scoped sections', () => {
scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'a' })
expect(() => scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'b' })).toThrow(/already registered in this scope/)
})
it.each([
[['reserved'], 'section "reserved"'],
[['first', 'second'], 'sections "first", "second"'],
])('rejects global protection added after scoped shadows (%j)', async (names, message) => {
const ctx = await mount()
const scope = await mintScope(ctx, 'child')
for (const name of names) {
scope.ctx.systemPrompt.section({ name, order: 1, text: `scoped ${name}` })
}
expect(() => ctx.systemPrompt.protect({ sections: names })).toThrow(message)
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })))
.toContain(`scoped ${names[0]}`)
})
})
describe('scoped variables', () => {
@@ -148,4 +163,31 @@ describe('scoped assemble dispatch', () => {
expect(global.sections.some(s => s.name === 'listener:extra')).toBe(false)
expect(shaped).toHaveLength(1)
})
it('a scoped protection finalizes only its own assemblies and disappears with the scope', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'child')
const key = scopeKeyOf(scope)
ctx.systemPrompt.section({ name: 'required', order: 10, text: 'required' })
ctx.systemPrompt.tools(() => ({ schemas: [schema('required')] }))
scope.ctx.systemPrompt.protect({ sections: ['required'], tools: ['required'] })
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const result = await next()
result.sections = result.sections.filter(section => section.name !== 'required')
result.tools = result.tools.filter(tool => tool.name !== 'required')
return result
}, { prepend: true })
const scoped = await ctx.systemPrompt.assemble({ scope: key })
const global = await ctx.systemPrompt.assemble()
expect(scoped.sections.some(section => section.name === 'required')).toBe(true)
expect(scoped.tools.some(tool => tool.name === 'required')).toBe(true)
expect(global.sections.some(section => section.name === 'required')).toBe(false)
expect(global.tools.some(tool => tool.name === 'required')).toBe(false)
await scope.dispose()
const disposed = await ctx.systemPrompt.assemble({ scope: key })
expect(disposed.sections.some(section => section.name === 'required')).toBe(false)
expect(disposed.tools.some(tool => tool.name === 'required')).toBe(false)
})
})

View File

@@ -205,6 +205,103 @@ describe('SystemPrompt', () => {
expect(assembly.sections).toHaveLength(0)
})
describe('canonical contribution protection', () => {
it('restores exact protected definitions after every listener, in canonical relative order', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
ctx.systemPrompt.section({ name: 'before', order: 10, text: 'before' })
ctx.systemPrompt.section({ name: 'protected', order: 20, text: 'canonical section' })
ctx.systemPrompt.section({ name: 'after', order: 30, text: 'after' })
ctx.systemPrompt.tools(() => ({ schemas: [
{ name: 'alpha', description: 'alpha', parameters: {} },
{ name: 'protected', description: 'canonical tool', parameters: { type: 'object', properties: { answer: { type: 'number' } } } },
{ name: 'zulu', description: 'zulu', parameters: {} },
] }))
const protection = { sections: ['protected'], tools: ['protected'] }
ctx.systemPrompt.protect(protection)
// Registration snapshots its arrays; caller mutation cannot change what
// the service makes authoritative.
protection.sections[0] = 'after'
protection.tools[0] = 'zulu'
// Registered AFTER the protection and prepended: it is outside every
// ordinary listener that existed when protect() ran, but service-level
// finalization still restores the canonical entries after it returns.
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const result = await next()
return Object.freeze({
sections: [
...result.sections.filter(section => section.name !== 'protected'),
{ name: 'protected', order: -999, text: 'wrong section' },
{ name: 'protected', order: 999, text: 'duplicate section' },
],
tools: [
...result.tools.filter(tool => tool.name !== 'protected'),
{ name: 'protected', description: 'wrong tool', parameters: {} },
{ name: 'protected', description: 'duplicate tool', parameters: {} },
],
variables: result.variables,
})
}, { prepend: true })
const assembly = await ctx.systemPrompt.assemble()
const protectedSections = assembly.sections.filter(section => section.name === 'protected')
const protectedTools = assembly.tools.filter(tool => tool.name === 'protected')
expect(protectedSections).toEqual([{ name: 'protected', order: 20, text: 'canonical section' }])
expect(protectedTools).toEqual([{
name: 'protected',
description: 'canonical tool',
parameters: { type: 'object', properties: { answer: { type: 'number' } } },
}])
expect(assembly.sections.map(section => section.name).indexOf('protected'))
.toBeLessThan(assembly.sections.map(section => section.name).indexOf('after'))
expect(assembly.tools.map(tool => tool.name)).toEqual(['alpha', 'protected', 'zulu'])
})
it('protects canonical absence and rejects an empty protection', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
// Separate registrations exercise the set-union contract: protections
// may name only sections or only tools and still compose.
ctx.systemPrompt.protect({ sections: ['mode-hidden'] })
ctx.systemPrompt.protect({ tools: ['mode-hidden'] })
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const result = await next()
result.sections.push({ name: 'mode-hidden', order: 100, text: 'fabricated' })
result.tools.push({ name: 'mode-hidden', description: 'fabricated', parameters: {} })
return result
})
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.sections.some(section => section.name === 'mode-hidden')).toBe(false)
expect(assembly.tools.some(tool => tool.name === 'mode-hidden')).toBe(false)
expect(() => ctx.systemPrompt.protect({})).toThrow(/at least one section or tool name/)
expect(() => ctx.systemPrompt.protect({ sections: [], tools: [] })).toThrow(/at least one section or tool name/)
})
it('removes a protection with its contributing fiber (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
ctx.systemPrompt.section({ name: 'protected', order: 10, text: 'canonical' })
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const result = await next()
result.sections = result.sections.filter(section => section.name !== 'protected')
return result
})
let changes = 0
ctx.on('system-prompt/change', () => { changes++ })
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.systemPrompt.protect({ sections: ['protected'] })
}, { inject: ['systemPrompt'] }))
expect((await ctx.systemPrompt.assemble()).sections.some(section => section.name === 'protected')).toBe(true)
expect(changes).toBe(1)
await fiber.dispose()
expect((await ctx.systemPrompt.assemble()).sections.some(section => section.name === 'protected')).toBe(false)
expect(changes).toBe(2)
})
})
it('assembles snapshots so one-step mutations do not leak into future assemblies', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)