fix(scope): harden final ownership boundaries
This commit is contained in:
@@ -13,10 +13,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 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. Each input array is read once and snapshotted, empty protections throw, and disposal removes the protection.
|
||||
- `ctx.systemPrompt.section(section: PromptSection): () => Promise<void> | void` Contribute a section. The registry reads `name`, `order`, and the text value/callback once, validates their fixed string/finite-number/string-or-function types, and stores only that accepted record; later caller-object mutation cannot rename or reshape it. 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; a non-function provider rejects before effect storage. `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}}`. The fixed string name and function provider types reject before effect storage. 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. Each optional field and array slot is read once, non-array fields or non-string names reject before effect storage, and the accepted arrays are deduplicated and frozen. Finalization materializes each waterfall-produced entry name once, so a stateful getter cannot evade canonical replacement. 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
|
||||
|
||||
@@ -234,11 +234,41 @@ function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined, knownN
|
||||
name === TOOL_ORDER_REST ? rest : tools.filter(tool => tool.name === name))
|
||||
}
|
||||
|
||||
/** Snapshot one waterfall-produced named entry with a stable, own data `name`. */
|
||||
function snapshotNamedEntry<T extends { name: string }>(entry: T): { entry: T; name: string } {
|
||||
// Read the name exactly once before protection matching. The waterfall owns
|
||||
// its output and may return accessor-backed records; retaining such an entry
|
||||
// would let a getter answer "unprotected" during filtering and the protected
|
||||
// name later when a consumer reads the final assembly.
|
||||
const name = entry.name
|
||||
const snapshot: Record<string, unknown> = {}
|
||||
Object.defineProperty(snapshot, 'name', {
|
||||
value: name,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
// Copy every other enumerable field once while deliberately skipping name.
|
||||
// defineProperty keeps a literal "__proto__" extension field ordinary data.
|
||||
for (const key of Object.keys(entry)) {
|
||||
if (key === 'name') continue
|
||||
Object.defineProperty(snapshot, key, {
|
||||
value: (entry as unknown as Record<string, unknown>)[key],
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
}
|
||||
return { entry: snapshot as T, 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))
|
||||
const restored = result
|
||||
.map(snapshotNamedEntry)
|
||||
.filter(record => !protectedNames.has(record.name))
|
||||
for (const [index, entry] of canonical.entries()) {
|
||||
if (!protectedNames.has(entry.name)) continue
|
||||
// Protected entries are inserted in canonical order. Anchor each one
|
||||
@@ -251,9 +281,29 @@ function restoreProtected<T extends { name: string }>(
|
||||
.map(candidate => candidate.name),
|
||||
)
|
||||
const next = restored.findIndex(candidate => following.has(candidate.name))
|
||||
restored.splice(next < 0 ? restored.length : next, 0, structuredClone(entry))
|
||||
restored.splice(next < 0 ? restored.length : next, 0, {
|
||||
entry: structuredClone(entry),
|
||||
name: entry.name,
|
||||
})
|
||||
}
|
||||
return restored
|
||||
return restored.map(record => record.entry)
|
||||
}
|
||||
|
||||
/** Validate and detach one protection-name array without rereading an element. */
|
||||
function snapshotProtectionNames(value: unknown, field: 'sections' | 'tools'): readonly string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new TypeError(`systemPrompt.protect() ${field} must be an array of strings`)
|
||||
}
|
||||
const names: string[] = []
|
||||
const length = value.length
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const name: unknown = value[index]
|
||||
if (typeof name !== 'string') {
|
||||
throw new TypeError(`systemPrompt.protect() ${field} must be an array of strings`)
|
||||
}
|
||||
names.push(name)
|
||||
}
|
||||
return Object.freeze([...new Set(names)])
|
||||
}
|
||||
|
||||
/** Lexicographic (code-unit) name comparison — locale-independent, so the order is identical on every machine. */
|
||||
@@ -433,9 +483,10 @@ export class SystemPrompt extends Service {
|
||||
* `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
|
||||
* registry reads `name`, `order`, and `text` once, validates their fixed
|
||||
* string/finite-number/string-or-function types, and stores only that
|
||||
* accepted record, so later caller-object mutation cannot rename or reshape
|
||||
* 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
|
||||
@@ -446,12 +497,23 @@ export class SystemPrompt extends Service {
|
||||
* yield it directly — exact identity nests the teardown in order.
|
||||
*/
|
||||
section(section: PromptSection): () => Promise<void> | void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
const snapshot: PromptSection = {
|
||||
name: section.name,
|
||||
order: section.order,
|
||||
text: section.text,
|
||||
const input: unknown = section
|
||||
if (typeof input !== 'object' || input === null) {
|
||||
throw new TypeError('systemPrompt.section() requires a section object')
|
||||
}
|
||||
const accepted = input as PromptSection
|
||||
const name = accepted.name
|
||||
const order = accepted.order
|
||||
const text = accepted.text
|
||||
if (typeof name !== 'string') throw new TypeError('prompt section name must be a string')
|
||||
if (typeof order !== 'number' || !Number.isFinite(order)) {
|
||||
throw new TypeError(`prompt section "${name}" order must be a finite number`)
|
||||
}
|
||||
if (typeof text !== 'string' && typeof text !== 'function') {
|
||||
throw new TypeError(`prompt section "${name}" text must be a string or function`)
|
||||
}
|
||||
const scope = scopeOf(this.ctx)
|
||||
const snapshot: PromptSection = { name, order, 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`)
|
||||
}
|
||||
@@ -498,7 +560,8 @@ export class SystemPrompt extends Service {
|
||||
* `schemas`/`knownNames` split). The layer is decided by the calling
|
||||
* context: a scoped provider (registered through `agent.ctx`) is consulted
|
||||
* only for that scope's assemblies. Removed when the calling fiber is
|
||||
* disposed. A provider must not return a schema named
|
||||
* disposed. A non-function provider is rejected before any effect is stored.
|
||||
* A provider must not return a schema named
|
||||
* {@link TOOL_ORDER_REST}; that name is reserved for
|
||||
* {@link Config.toolOrder}'s rest entry and rejects the assembly. Emits
|
||||
* `system-prompt/change`.
|
||||
@@ -508,6 +571,9 @@ export class SystemPrompt extends Service {
|
||||
* yield it directly — exact identity nests the teardown in order.
|
||||
*/
|
||||
tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise<void> | void {
|
||||
if (typeof provider !== 'function') {
|
||||
throw new TypeError('system prompt tool provider must be a function')
|
||||
}
|
||||
const scope = scopeOf(this.ctx)
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
const layer = scope === undefined
|
||||
@@ -545,10 +611,11 @@ export class SystemPrompt extends Service {
|
||||
* deployment must not claim facts it does not have). The layer is decided
|
||||
* by the calling context: a scoped variable (registered through
|
||||
* `agent.ctx`) resolves only for that scope's assemblies and SHADOWS a
|
||||
* same-named global variable there. Throws on a name that does not match
|
||||
* `[a-z][a-z0-9_]*` (it could never be referenced) or one already
|
||||
* registered in the SAME layer. Removed when the calling fiber is disposed;
|
||||
* emits `system-prompt/change` on register/unregister.
|
||||
* same-named global variable there. The fixed name and callback types are
|
||||
* validated before effect storage. Throws on a name that does not match
|
||||
* `[a-z][a-z0-9_]*` (it could never be referenced) or one already registered
|
||||
* in the SAME layer. Removed when the calling fiber is disposed; emits
|
||||
* `system-prompt/change` on register/unregister.
|
||||
* @param name - the reference name (matches `[a-z][a-z0-9_]*`).
|
||||
* @param provider - evaluated at every {@link assemble} for the value.
|
||||
* @returns the disposer that removes the variable. The exact
|
||||
@@ -556,11 +623,16 @@ export class SystemPrompt extends Service {
|
||||
* yield it directly — exact identity nests the teardown in order.
|
||||
*/
|
||||
variable(name: string, provider: (context: AssembleContext) => string | undefined): () => Promise<void> | void {
|
||||
const inputName: unknown = name
|
||||
if (typeof inputName !== 'string') throw new TypeError('prompt variable name must be a string')
|
||||
if (!VARIABLE_NAME.test(inputName)) {
|
||||
throw new Error(`invalid prompt variable name "${inputName}" (must match ${String(VARIABLE_NAME)})`)
|
||||
}
|
||||
if (typeof provider !== 'function') {
|
||||
throw new TypeError(`prompt variable "${inputName}" provider must be a function`)
|
||||
}
|
||||
const scope = scopeOf(this.ctx)
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
if (!VARIABLE_NAME.test(name)) {
|
||||
throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`)
|
||||
}
|
||||
const layer = scope === undefined
|
||||
? this.variableProviders
|
||||
: this.scopedVariableProviders.get(scope) ?? (() => {
|
||||
@@ -599,9 +671,13 @@ 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. Each input array is
|
||||
* read once and snapshotted; an empty protection throws because it cannot
|
||||
* affect output.
|
||||
* listener-injected entry with that name is removed. Each optional field and
|
||||
* array slot is read once; non-array fields or non-string names reject before
|
||||
* effect storage, and the accepted deduplicated arrays are frozen. During
|
||||
* finalization each waterfall-produced entry name is likewise read once into
|
||||
* an owned data record, so a stateful getter cannot look unprotected during
|
||||
* filtering and later impersonate a protected name. 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
|
||||
@@ -610,13 +686,24 @@ export class SystemPrompt extends Service {
|
||||
* @returns the exact Cordis effect disposer that removes the protection.
|
||||
*/
|
||||
protect(protection: PromptProtection): () => Promise<void> | void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
const sections = protection.sections
|
||||
const tools = protection.tools
|
||||
const snapshot: PromptProtection = {
|
||||
...sections !== undefined ? { sections: [...new Set(sections)] } : {},
|
||||
...tools !== undefined ? { tools: [...new Set(tools)] } : {},
|
||||
const input: unknown = protection
|
||||
if (typeof input !== 'object' || input === null) {
|
||||
throw new TypeError('systemPrompt.protect() requires a protection object')
|
||||
}
|
||||
const accepted = input as PromptProtection
|
||||
const inputSections = accepted.sections
|
||||
const inputTools = accepted.tools
|
||||
const sections = inputSections === undefined
|
||||
? undefined
|
||||
: snapshotProtectionNames(inputSections, 'sections')
|
||||
const tools = inputTools === undefined
|
||||
? undefined
|
||||
: snapshotProtectionNames(inputTools, 'tools')
|
||||
const scope = scopeOf(this.ctx)
|
||||
const snapshot: PromptProtection = Object.freeze({
|
||||
...sections !== undefined ? { sections } : {},
|
||||
...tools !== undefined ? { 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')
|
||||
}
|
||||
|
||||
@@ -111,6 +111,86 @@ describe('SystemPrompt', () => {
|
||||
expect(contributed(assembly).map(s => s.text)).toEqual(['first'])
|
||||
})
|
||||
|
||||
it('rejects malformed fixed registration fields before storing an effect', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
const badName = { value: 'name' }
|
||||
const badText = { value: 'text' }
|
||||
|
||||
expect(() => ctx.systemPrompt.section(null as unknown as Parameters<typeof ctx.systemPrompt.section>[0]))
|
||||
.toThrow('requires a section object')
|
||||
expect(() => ctx.systemPrompt.section(1 as unknown as Parameters<typeof ctx.systemPrompt.section>[0]))
|
||||
.toThrow('requires a section object')
|
||||
expect(() => ctx.systemPrompt.section({ name: badName as unknown as string, order: 1, text: 'x' }))
|
||||
.toThrow('prompt section name must be a string')
|
||||
expect(() => ctx.systemPrompt.section({ name: 'bad-order', order: '1' as unknown as number, text: 'x' }))
|
||||
.toThrow('order must be a finite number')
|
||||
expect(() => ctx.systemPrompt.section({ name: 'bad-order', order: Number.NaN, text: 'x' }))
|
||||
.toThrow('order must be a finite number')
|
||||
expect(() => ctx.systemPrompt.section({ name: 'bad-text', order: 1, text: badText as unknown as string }))
|
||||
.toThrow('text must be a string or function')
|
||||
expect(() => ctx.systemPrompt.tools(1 as unknown as Parameters<typeof ctx.systemPrompt.tools>[0]))
|
||||
.toThrow('tool provider must be a function')
|
||||
expect(() => ctx.systemPrompt.variable({} as unknown as string, () => 'x'))
|
||||
.toThrow('prompt variable name must be a string')
|
||||
expect(() => ctx.systemPrompt.variable('valid', 1 as unknown as Parameters<typeof ctx.systemPrompt.variable>[1]))
|
||||
.toThrow('provider must be a function')
|
||||
expect(() => ctx.systemPrompt.protect(null as unknown as Parameters<typeof ctx.systemPrompt.protect>[0]))
|
||||
.toThrow('requires a protection object')
|
||||
expect(() => ctx.systemPrompt.protect(1 as unknown as Parameters<typeof ctx.systemPrompt.protect>[0]))
|
||||
.toThrow('requires a protection object')
|
||||
expect(() => ctx.systemPrompt.protect({ sections: 'x' as unknown as string[] }))
|
||||
.toThrow('sections must be an array of strings')
|
||||
expect(() => ctx.systemPrompt.protect({ tools: 'x' as unknown as string[] }))
|
||||
.toThrow('tools must be an array of strings')
|
||||
expect(() => ctx.systemPrompt.protect({ sections: ['ok', {} as unknown as string] }))
|
||||
.toThrow('sections must be an array of strings')
|
||||
expect(() => ctx.systemPrompt.protect({ tools: [{} as unknown as string] }))
|
||||
.toThrow('tools must be an array of strings')
|
||||
|
||||
expect(Object.isFrozen(badName)).toBe(false)
|
||||
expect(Object.isFrozen(badText)).toBe(false)
|
||||
expect(contributed(await ctx.systemPrompt.assemble())).toEqual([])
|
||||
})
|
||||
|
||||
it('reads each section field and protection-name slot once at registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
const reads = { name: 0, order: 0, text: 0, sections: 0, item: 0 }
|
||||
const section = Object.defineProperties({}, {
|
||||
name: {
|
||||
enumerable: true,
|
||||
get: () => (++reads.name === 1 ? 'stable' : 42),
|
||||
},
|
||||
order: {
|
||||
enumerable: true,
|
||||
get: () => (++reads.order === 1 ? 10 : Number.NaN),
|
||||
},
|
||||
text: {
|
||||
enumerable: true,
|
||||
get: () => (++reads.text === 1 ? 'stable text' : null),
|
||||
},
|
||||
}) as unknown as Parameters<typeof ctx.systemPrompt.section>[0]
|
||||
const names = new Array<string>(1)
|
||||
Object.defineProperty(names, 0, {
|
||||
enumerable: true,
|
||||
get: () => (++reads.item === 1 ? 'stable' : 'drifted'),
|
||||
})
|
||||
const protection = {
|
||||
get sections(): string[] {
|
||||
reads.sections += 1
|
||||
return reads.sections === 1 ? names : ['drifted']
|
||||
},
|
||||
}
|
||||
|
||||
ctx.systemPrompt.section(section)
|
||||
ctx.systemPrompt.protect(protection)
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
|
||||
expect(reads).toEqual({ name: 1, order: 1, text: 1, sections: 1, item: 1 })
|
||||
expect(assembly.sections).toContainEqual({ name: 'stable', order: 10, text: 'stable text' })
|
||||
})
|
||||
|
||||
it('rolls back a section when a system-prompt/change listener throws (P1-1)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
@@ -282,6 +362,56 @@ describe('SystemPrompt', () => {
|
||||
expect(assembly.sections).toContainEqual({ name: 'protected', order: 10, text: 'canonical' })
|
||||
})
|
||||
|
||||
it('materializes waterfall entry names once before restoring protected definitions', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.section({ name: 'protected', order: 10, text: 'canonical section' })
|
||||
ctx.systemPrompt.tools(() => ({ schemas: [{ name: 'protected', description: 'canonical tool', parameters: {} }] }))
|
||||
ctx.systemPrompt.protect({ sections: ['protected'], tools: ['protected'] })
|
||||
let sectionNameReads = 0
|
||||
let toolNameReads = 0
|
||||
const hostileSection = {
|
||||
get name(): string {
|
||||
sectionNameReads += 1
|
||||
return sectionNameReads === 1 ? 'impostor-section' : 'protected'
|
||||
},
|
||||
order: 999,
|
||||
text: 'listener section',
|
||||
}
|
||||
const hostileTool = {
|
||||
get name(): string {
|
||||
toolNameReads += 1
|
||||
return toolNameReads === 1 ? 'impostor-tool' : 'protected'
|
||||
},
|
||||
description: 'listener tool',
|
||||
parameters: {},
|
||||
}
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const result = await next()
|
||||
result.sections = [
|
||||
...result.sections.filter(section => section.name !== 'protected'),
|
||||
hostileSection,
|
||||
]
|
||||
result.tools = [
|
||||
...result.tools.filter(tool => tool.name !== 'protected'),
|
||||
hostileTool,
|
||||
]
|
||||
return result
|
||||
})
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
|
||||
expect(sectionNameReads).toBe(1)
|
||||
expect(toolNameReads).toBe(1)
|
||||
expect(assembly.sections.map(section => section.name)).toEqual([
|
||||
'harness:identity',
|
||||
'deployment:persona',
|
||||
'impostor-section',
|
||||
'protected',
|
||||
])
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['impostor-tool', 'protected'])
|
||||
})
|
||||
|
||||
it('protects canonical absence and rejects an empty protection', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
Reference in New Issue
Block a user