From de3e0a5d1cd3d44963a17d0afcec8d51f412ef35 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 7 Aug 2026 20:55:58 +0800 Subject: [PATCH] fix(schedule): validate exact time-context marker shapes --- .../context/time-context/src/invariant.ts | 28 +++++-- .../time-context/tests/invariant.spec.ts | 84 +++++++++++++++++++ packages/schedule/tool-schedule/src/tools.ts | 22 +++-- .../tool-schedule/tests/tools.spec.ts | 59 +++++++++++++ 4 files changed, 177 insertions(+), 16 deletions(-) diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index 79917ad6bf..ea2b378528 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -67,11 +67,19 @@ function validateReading( event: SessionEvent<'user/message'>, fail: InvariantFailure, ): void { - const [block] = event.data.content - if (event.data.content.length !== 1 || block?.type !== 'text') { + const blockValue: unknown = event.data.content[0] + const block = typeof blockValue === 'object' && blockValue !== null + ? blockValue as Record + : undefined + const blockText = block?.text + if (event.data.content.length !== 1 + || block === undefined + || Object.keys(block).length !== 2 + || block.type !== 'text' + || typeof blockText !== 'string') { fail('time-context messages must contain exactly one text block') } - const match = READING.exec(block.text) + const match = READING.exec(blockText) if (match === null) fail('time-context message does not match the durable reading format') const turn = Number(match[1]) const step = Number(match[2]) @@ -88,17 +96,19 @@ function validateReading( fail('time-context source must retain package ownership') } const sections: unknown = 'sections' in source ? source.sections : undefined - const section: unknown = Array.isArray(sections) ? sections[0] : undefined + const sectionValue: unknown = Array.isArray(sections) ? sections[0] : undefined + const section = typeof sectionValue === 'object' && sectionValue !== null + ? sectionValue as Record + : undefined if (Object.keys(source).length !== 4 || source.form !== 'snapshot' || !Array.isArray(sections) || sections.length !== 1 - || typeof section !== 'object' - || section === null - || !('name' in section) + || section === undefined + || Object.keys(section).length !== 2 || section.name !== SOURCE_NAME - || !('text' in section) - || section.text !== block.text) { + || typeof section.text !== 'string' + || section.text !== blockText) { fail('time-context source must carry only the exact snapshot text, not request authority') } const renderedAuthority = `Session time zone: ${match[4]}.\nClient time zone for this request: ${match[5]}.` diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index 926af82227..df2db1397e 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -155,6 +155,90 @@ describe('time-context invariants', () => { }).toThrow(/must carry only the exact snapshot text/) }) + it('rejects snapshot provenance whose section differs from the model-visible text', async () => { + const ctx = await setup() + const base = event(reading()) + const mismatched: SessionEvent<'user/message'> = { + ...base, + data: { + ...base.data, + source: { + kind: 'plugin', + plugin: 'time-context', + form: 'snapshot', + sections: [{ name: 'time-context', text: 'different' }], + }, + }, + } + expect(() => { + ctx.emit('session/event', preparing(1, 1), mismatched) + }).toThrow(/must carry only the exact snapshot text/) + }) + + it('rejects snapshot provenance whose sections are only array-like', async () => { + const ctx = await setup() + const base = event(reading()) + const arrayLike: SessionEvent<'user/message'> = { + ...base, + data: { + ...base.data, + source: { + kind: 'plugin', + plugin: 'time-context', + form: 'snapshot', + sections: { 0: { name: 'time-context', text: reading() }, length: 1 }, + } as never, + }, + } + expect(() => { + ctx.emit('session/event', preparing(1, 1), arrayLike) + }).toThrow(/must carry only the exact snapshot text/) + }) + + it.each([ + [ + 'matched non-string text', + { type: 'text', text: 7 }, + [{ name: 'time-context', text: 7 }], + /must contain exactly one text block/, + ], + [ + 'an extra text-block field', + { type: 'text', text: reading(), extra: true }, + [{ name: 'time-context', text: reading() }], + /must contain exactly one text block/, + ], + [ + 'non-string section text', + { type: 'text', text: reading() }, + [{ name: 'time-context', text: 7 }], + /must carry only the exact snapshot text/, + ], + [ + 'an extra section field', + { type: 'text', text: reading() }, + [{ name: 'time-context', text: reading(), extra: true }], + /must carry only the exact snapshot text/, + ], + ] as const)( + 'rejects snapshot provenance with %s', + async (_name, block, sections, diagnostic) => { + const ctx = await setup() + const base = event(reading()) + const malformed: SessionEvent<'user/message'> = { + ...base, + data: { + ...base.data, + content: [block as never], + source: { kind: 'plugin', plugin: 'time-context', form: 'snapshot', sections } as never, + }, + } + expect(() => { + ctx.emit('session/event', preparing(1, 1), malformed) + }).toThrow(diagnostic) + }, + ) + it('rejects package-owned provenance without snapshot sections', async () => { const ctx = await setup() const base = event(reading()) diff --git a/packages/schedule/tool-schedule/src/tools.ts b/packages/schedule/tool-schedule/src/tools.ts index 93848c3954..40d89f219d 100644 --- a/packages/schedule/tool-schedule/src/tools.ts +++ b/packages/schedule/tool-schedule/src/tools.ts @@ -226,18 +226,26 @@ function isTimeContextReading(event: SessionEvent): boolean { || source.plugin !== 'time-context' || Object.keys(source).length !== 4 || source.form !== 'snapshot') return false - const [block] = event.data.content + const blockValue: unknown = event.data.content[0] + const block = typeof blockValue === 'object' && blockValue !== null + ? blockValue as Record + : undefined const sections: unknown = source.sections - const section: unknown = Array.isArray(sections) ? sections[0] : undefined + const sectionValue: unknown = Array.isArray(sections) ? sections[0] : undefined + const section = typeof sectionValue === 'object' && sectionValue !== null + ? sectionValue as Record + : undefined return event.data.content.length === 1 - && block?.type === 'text' + && block !== undefined + && Object.keys(block).length === 2 + && block.type === 'text' + && typeof block.text === 'string' && Array.isArray(sections) && sections.length === 1 - && typeof section === 'object' - && section !== null - && 'name' in section + && section !== undefined + && Object.keys(section).length === 2 && section.name === 'time-context' - && 'text' in section + && typeof section.text === 'string' && section.text === block.text } diff --git a/packages/schedule/tool-schedule/tests/tools.spec.ts b/packages/schedule/tool-schedule/tests/tools.spec.ts index 1c99450575..b343ef9ae9 100644 --- a/packages/schedule/tool-schedule/tests/tools.spec.ts +++ b/packages/schedule/tool-schedule/tests/tools.spec.ts @@ -359,6 +359,65 @@ describe('Schedule tool protocol', () => { }) }) + it('does not let an array-like snapshot marker authorize an implicit local at', async () => { + const test = await harness(true, 'Asia/Shanghai') + test.agent.session.append('turn/start', { turn: 1 }) + test.agent.session.append('step/start', { turn: 1, step: 1 }) + test.agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'request' }], + source: { kind: 'user', clientTimeZone: 'Asia/Shanghai' } as never, + }), { surfaceOp: 'append' }) + const text = 'time context' + test.agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text }], + source: { + kind: 'plugin', + plugin: 'time-context', + form: 'snapshot', + sections: { 0: { name: 'time-context', text }, length: 1 }, + } as never, + }), { surfaceOp: 'append' }) + + expect(value(await execute(test, 'schedule_create', { + prompt: 'malformed marker', at: { date: '2026-08-06', time: '09:00:00' }, + }))).toMatchObject({ + code: 'timezone_confirmation_required', + sessionTimeZone: 'Asia/Shanghai', + clientTimeZones: [], + }) + }) + + it.each([ + ['a non-object text block', 7, [{ name: 'time-context', text: 'time context' }]], + ['matched non-string text', { type: 'text', text: 7 }, [{ name: 'time-context', text: 7 }]], + ['extra text-block field', { type: 'text', text: 'time context', extra: true }, [{ name: 'time-context', text: 'time context' }]], + ['non-string section text', { type: 'text', text: 'time context' }, [{ name: 'time-context', text: 7 }]], + ['extra section field', { type: 'text', text: 'time context' }, [{ name: 'time-context', text: 'time context', extra: true }]], + ] as const)( + 'does not let snapshot provenance with %s authorize an implicit local at', + async (_name, block, sections) => { + const test = await harness(true, 'Asia/Shanghai') + test.agent.session.append('turn/start', { turn: 1 }) + test.agent.session.append('step/start', { turn: 1, step: 1 }) + test.agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'request' }], + source: { kind: 'user', clientTimeZone: 'Asia/Shanghai' } as never, + }), { surfaceOp: 'append' }) + test.agent.session.append('user/message', createUserMessage({ + content: [block as never], + source: { kind: 'plugin', plugin: 'time-context', form: 'snapshot', sections } as never, + }), { surfaceOp: 'append' }) + + expect(value(await execute(test, 'schedule_create', { + prompt: 'malformed marker', at: { date: '2026-08-06', time: '09:00:00' }, + }))).toMatchObject({ + code: 'timezone_confirmation_required', + sessionTimeZone: 'Asia/Shanghai', + clientTimeZones: [], + }) + }, + ) + it.each(['step/end', 'turn/end'] as const)( 'fails closed after the current %s boundary', async (boundary) => {