Browse Source

fix(schedule): validate exact time-context marker shapes

pku-xht 2 months ago
parent
commit
de3e0a5d1c

+ 19 - 9
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<string, unknown>
+    : 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<string, unknown>
+    : 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]}.`

+ 84 - 0
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())

+ 15 - 7
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<string, unknown>
+    : 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<string, unknown>
+    : 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
 }
 

+ 59 - 0
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) => {