ocpipe 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/parsing.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * DSTS SDK response parsing.
2
+ * ocpipe response parsing.
3
3
  *
4
4
  * Extracts and validates LLM responses using JSON or field marker formats.
5
5
  */
@@ -77,7 +77,13 @@ export function tryParseJson<T>(
77
77
  if (!jsonStr) {
78
78
  return {
79
79
  ok: false,
80
- errors: [{ path: '', message: 'No JSON found in response', expectedType: 'object' }],
80
+ errors: [
81
+ {
82
+ path: '',
83
+ message: 'No JSON found in response',
84
+ expectedType: 'object',
85
+ },
86
+ ],
81
87
  }
82
88
  }
83
89
 
@@ -88,7 +94,13 @@ export function tryParseJson<T>(
88
94
  const parseErr = e as SyntaxError
89
95
  return {
90
96
  ok: false,
91
- errors: [{ path: '', message: `JSON parse failed: ${parseErr.message}`, expectedType: 'object' }],
97
+ errors: [
98
+ {
99
+ path: '',
100
+ message: `JSON parse failed: ${parseErr.message}`,
101
+ expectedType: 'object',
102
+ },
103
+ ],
92
104
  }
93
105
  }
94
106
 
@@ -131,9 +143,10 @@ function zodErrorsToFieldErrors(
131
143
  let foundValue: unknown
132
144
 
133
145
  // Check if field is missing (received undefined)
134
- const isMissing = issue.code === 'invalid_type' &&
146
+ const isMissing =
147
+ issue.code === 'invalid_type' &&
135
148
  (issue as { received?: string }).received === 'undefined'
136
-
149
+
137
150
  if (isMissing) {
138
151
  // Field is missing - look for similar field names in parsed data
139
152
  const similar = findSimilarField(fieldName, parsed, schemaKeys)
@@ -187,14 +200,21 @@ function findSimilarField(
187
200
  for (const key of extraKeys) {
188
201
  const normalizedKey = key.toLowerCase().replace(/_/g, '')
189
202
  if (normalizedKey === normalized) return key
190
- if (normalizedKey.includes(normalized) || normalized.includes(normalizedKey)) return key
203
+ if (
204
+ normalizedKey.includes(normalized) ||
205
+ normalized.includes(normalizedKey)
206
+ )
207
+ return key
191
208
  }
192
209
 
193
210
  return undefined
194
211
  }
195
212
 
196
213
  /** getExpectedType extracts a human-readable type description from a Zod issue. */
197
- function getExpectedType(issue: z.ZodIssue, schema: Record<string, FieldConfig>): string {
214
+ function getExpectedType(
215
+ issue: z.ZodIssue,
216
+ schema: Record<string, FieldConfig>,
217
+ ): string {
198
218
  const fieldName = issue.path[0] as string
199
219
  const fieldConfig = schema[fieldName]
200
220
 
@@ -225,7 +245,9 @@ export function zodTypeToString(zodType: z.ZodType): string {
225
245
  return `enum[${opts.map((v) => `"${v}"`).join(', ')}]`
226
246
  }
227
247
  // Fallback to _def
228
- const def = (zodType as unknown as { _def?: { values?: readonly string[] } })._def
248
+ const def = (
249
+ zodType as unknown as { _def?: { values?: readonly string[] } }
250
+ )._def
229
251
  const values = def?.values ?? []
230
252
  if (values.length > 0) {
231
253
  return `enum[${values.map((v) => `"${v}"`).join(', ')}]`
@@ -242,7 +264,9 @@ export function zodTypeToString(zodType: z.ZodType): string {
242
264
  }
243
265
  if (zodType instanceof z.ZodObject) {
244
266
  // ZodObject has .shape property
245
- const shapeObj = (zodType as unknown as { shape?: Record<string, z.ZodType> }).shape
267
+ const shapeObj = (
268
+ zodType as unknown as { shape?: Record<string, z.ZodType> }
269
+ ).shape
246
270
  if (shapeObj) {
247
271
  const fields = Object.keys(shapeObj).slice(0, 3).join(', ')
248
272
  return `object{${fields}${Object.keys(shapeObj).length > 3 ? ', ...' : ''}}`
@@ -251,14 +275,18 @@ export function zodTypeToString(zodType: z.ZodType): string {
251
275
  }
252
276
  if (zodType instanceof z.ZodOptional) {
253
277
  // ZodOptional has .unwrap() method
254
- const unwrapped = (zodType as unknown as { unwrap?: () => z.ZodType }).unwrap?.()
278
+ const unwrapped = (
279
+ zodType as unknown as { unwrap?: () => z.ZodType }
280
+ ).unwrap?.()
255
281
  if (unwrapped) {
256
282
  return `optional<${zodTypeToString(unwrapped)}>`
257
283
  }
258
284
  return 'optional'
259
285
  }
260
286
  if (zodType instanceof z.ZodNullable) {
261
- const unwrapped = (zodType as unknown as { unwrap?: () => z.ZodType }).unwrap?.()
287
+ const unwrapped = (
288
+ zodType as unknown as { unwrap?: () => z.ZodType }
289
+ ).unwrap?.()
262
290
  if (unwrapped) {
263
291
  return `nullable<${zodTypeToString(unwrapped)}>`
264
292
  }
@@ -266,7 +294,8 @@ export function zodTypeToString(zodType: z.ZodType): string {
266
294
  }
267
295
  if (zodType instanceof z.ZodDefault) {
268
296
  // ZodDefault wraps inner type
269
- const inner = (zodType as unknown as { _def?: { innerType?: z.ZodType } })._def?.innerType
297
+ const inner = (zodType as unknown as { _def?: { innerType?: z.ZodType } })
298
+ ._def?.innerType
270
299
  if (inner) {
271
300
  return `default<${zodTypeToString(inner)}>`
272
301
  }
@@ -322,7 +351,9 @@ export function buildPatchPrompt(
322
351
  lines.push(`Expected type: ${error.expectedType}`)
323
352
 
324
353
  if (error.foundField) {
325
- lines.push(`Found similar field: "${error.foundField}" with value: ${JSON.stringify(error.foundValue)}`)
354
+ lines.push(
355
+ `Found similar field: "${error.foundField}" with value: ${JSON.stringify(error.foundValue)}`,
356
+ )
326
357
  }
327
358
 
328
359
  lines.push('')
@@ -354,9 +385,13 @@ export function buildBatchPatchPrompt(
354
385
  lines.push('ERRORS:')
355
386
  for (let i = 0; i < errors.length; i++) {
356
387
  const error = errors[i]!
357
- lines.push(`${i + 1}. Field "${error.path}": ${error.message} (expected: ${error.expectedType})`)
388
+ lines.push(
389
+ `${i + 1}. Field "${error.path}": ${error.message} (expected: ${error.expectedType})`,
390
+ )
358
391
  if (error.foundField) {
359
- lines.push(` Found similar: "${error.foundField}" = ${JSON.stringify(error.foundValue)}`)
392
+ lines.push(
393
+ ` Found similar: "${error.foundField}" = ${JSON.stringify(error.foundValue)}`,
394
+ )
360
395
  }
361
396
  }
362
397
 
@@ -366,7 +401,9 @@ export function buildBatchPatchPrompt(
366
401
  lines.push(JSON.stringify(abbreviateJson(currentJson), null, 2))
367
402
  lines.push('```')
368
403
  lines.push('')
369
- lines.push('Respond with jq-style patches to fix ALL errors. Use | to chain multiple patches.')
404
+ lines.push(
405
+ 'Respond with jq-style patches to fix ALL errors. Use | to chain multiple patches.',
406
+ )
370
407
  lines.push('Examples:')
371
408
  lines.push('- .field1 = "value" | .field2 = 123')
372
409
  lines.push('- .items[0].name = .items[0].title | del(.items[0].title)')
@@ -378,7 +415,10 @@ export function buildBatchPatchPrompt(
378
415
  }
379
416
 
380
417
  /** abbreviateJson truncates large values for display in prompts. */
381
- function abbreviateJson(obj: Record<string, unknown>, maxLength = 100): Record<string, unknown> {
418
+ function abbreviateJson(
419
+ obj: Record<string, unknown>,
420
+ maxLength = 100,
421
+ ): Record<string, unknown> {
382
422
  const result: Record<string, unknown> = {}
383
423
 
384
424
  for (const [key, value] of Object.entries(obj)) {
@@ -401,7 +441,9 @@ function abbreviateJson(obj: Record<string, unknown>, maxLength = 100): Record<s
401
441
  }
402
442
 
403
443
  /** convertNullToUndefined recursively converts null values to undefined (for optional fields). */
404
- function convertNullToUndefined(obj: Record<string, unknown>): Record<string, unknown> {
444
+ function convertNullToUndefined(
445
+ obj: Record<string, unknown>,
446
+ ): Record<string, unknown> {
405
447
  const result: Record<string, unknown> = {}
406
448
 
407
449
  for (const [key, value] of Object.entries(obj)) {
@@ -441,7 +483,10 @@ export function extractPatch(response: string): string {
441
483
  // If no clear patch found, try the whole response (minus markdown)
442
484
  const cleaned = response.replace(/```[^`]*```/g, '').trim()
443
485
  const firstLine = cleaned.split('\n')[0]?.trim()
444
- if (firstLine && (firstLine.startsWith('.') || firstLine.startsWith('del('))) {
486
+ if (
487
+ firstLine &&
488
+ (firstLine.startsWith('.') || firstLine.startsWith('del('))
489
+ ) {
445
490
  return firstLine
446
491
  }
447
492
 
@@ -462,20 +507,20 @@ export function applyJqPatch(
462
507
  /\binput\b/,
463
508
  /\binputs\b/,
464
509
  /\bsystem\b/,
465
- /\@base64d/,
466
- /\@uri/,
467
- /\@csv/,
468
- /\@tsv/,
469
- /\@json/,
470
- /\@text/,
471
- /\@sh/,
510
+ /@base64d/,
511
+ /@uri/,
512
+ /@csv/,
513
+ /@tsv/,
514
+ /@json/,
515
+ /@text/,
516
+ /@sh/,
472
517
  /`[^`]*`/, // Backtick string interpolation
473
518
  /\bimport\b/,
474
519
  /\binclude\b/,
475
520
  /\bdebug\b/,
476
521
  /\berror\b/,
477
522
  /\bhalt\b/,
478
- /\$/, // Any variable reference (safest to disallow all)
523
+ /\$/, // Any variable reference (safest to disallow all)
479
524
  ]
480
525
 
481
526
  for (const pattern of unsafePatterns) {
@@ -487,7 +532,7 @@ export function applyJqPatch(
487
532
 
488
533
  // Only allow patches that look like field operations
489
534
  // Valid: .foo = "bar", .items[0].name = .items[0].title, del(.foo) | .bar = 1
490
- const safePattern = /^[\s\w\[\]."'=|,:\-{}]*$/
535
+ const safePattern = /^[\s\w[\]."'=|,:\-{}]*$/
491
536
  if (!safePattern.test(patch)) {
492
537
  console.error(` Invalid characters in patch, skipping: ${patch}`)
493
538
  return obj
@@ -535,7 +580,9 @@ export function buildJsonPatchPrompt(
535
580
  lines.push(`Expected type: ${error.expectedType}`)
536
581
 
537
582
  if (error.foundField) {
538
- lines.push(`Found similar field: "${error.foundField}" with value: ${JSON.stringify(error.foundValue)}`)
583
+ lines.push(
584
+ `Found similar field: "${error.foundField}" with value: ${JSON.stringify(error.foundValue)}`,
585
+ )
539
586
  }
540
587
 
541
588
  lines.push('')
@@ -544,11 +591,17 @@ export function buildJsonPatchPrompt(
544
591
  lines.push(JSON.stringify(abbreviateJson(currentJson), null, 2))
545
592
  lines.push('```')
546
593
  lines.push('')
547
- lines.push('Respond with ONLY a JSON Patch array (RFC 6902) to fix this field. Examples:')
594
+ lines.push(
595
+ 'Respond with ONLY a JSON Patch array (RFC 6902) to fix this field. Examples:',
596
+ )
548
597
  lines.push('- [{"op": "add", "path": "/field_name", "value": "new_value"}]')
549
598
  lines.push('- [{"op": "replace", "path": "/field_name", "value": 123}]')
550
- lines.push('- [{"op": "move", "from": "/wrong_field", "path": "/correct_field"}]')
551
- lines.push('- [{"op": "remove", "path": "/wrong_field"}, {"op": "add", "path": "/correct_field", "value": "..."}]')
599
+ lines.push(
600
+ '- [{"op": "move", "from": "/wrong_field", "path": "/correct_field"}]',
601
+ )
602
+ lines.push(
603
+ '- [{"op": "remove", "path": "/wrong_field"}, {"op": "add", "path": "/correct_field", "value": "..."}]',
604
+ )
552
605
  lines.push('')
553
606
  lines.push('Your JSON Patch:')
554
607
 
@@ -567,9 +620,13 @@ export function buildBatchJsonPatchPrompt(
567
620
  lines.push('ERRORS:')
568
621
  for (let i = 0; i < errors.length; i++) {
569
622
  const error = errors[i]!
570
- lines.push(`${i + 1}. Field "${error.path}": ${error.message} (expected: ${error.expectedType})`)
623
+ lines.push(
624
+ `${i + 1}. Field "${error.path}": ${error.message} (expected: ${error.expectedType})`,
625
+ )
571
626
  if (error.foundField) {
572
- lines.push(` Found similar: "${error.foundField}" = ${JSON.stringify(error.foundValue)}`)
627
+ lines.push(
628
+ ` Found similar: "${error.foundField}" = ${JSON.stringify(error.foundValue)}`,
629
+ )
573
630
  }
574
631
  }
575
632
 
@@ -579,7 +636,9 @@ export function buildBatchJsonPatchPrompt(
579
636
  lines.push(JSON.stringify(abbreviateJson(currentJson), null, 2))
580
637
  lines.push('```')
581
638
  lines.push('')
582
- lines.push('Respond with a JSON Patch array (RFC 6902) to fix ALL errors. Examples:')
639
+ lines.push(
640
+ 'Respond with a JSON Patch array (RFC 6902) to fix ALL errors. Examples:',
641
+ )
583
642
  lines.push('- [{"op": "move", "from": "/type", "path": "/issue_type"}]')
584
643
  lines.push('- [{"op": "replace", "path": "/items/0/name", "value": "fixed"}]')
585
644
  lines.push('- [{"op": "add", "path": "/missing_field", "value": "default"}]')
@@ -589,44 +648,69 @@ export function buildBatchJsonPatchPrompt(
589
648
  return lines.join('\n')
590
649
  }
591
650
 
592
- /** extractJsonPatch extracts a JSON Patch array from an LLM response. */
593
- export function extractJsonPatch(response: string): JsonPatchOperation[] {
594
- // Try to find JSON array in code blocks first
595
- const codeBlockMatch = response.match(/```(?:json)?\s*(\[[\s\S]*?\])[\s\S]*?```/)
596
- if (codeBlockMatch?.[1]) {
597
- try {
598
- return JSON.parse(codeBlockMatch[1]) as JsonPatchOperation[]
599
- } catch {
600
- // Continue to try other methods
651
+ /**
652
+ * extractBalancedArray extracts a balanced JSON array from a string starting at startIdx.
653
+ * Returns the array substring or null if not found/unbalanced.
654
+ */
655
+ function extractBalancedArray(text: string, startIdx: number): string | null {
656
+ if (startIdx === -1 || startIdx >= text.length) return null
657
+
658
+ let bracketCount = 0
659
+ let endIdx = startIdx
660
+ for (let i = startIdx; i < text.length; i++) {
661
+ if (text[i] === '[') bracketCount++
662
+ else if (text[i] === ']') {
663
+ bracketCount--
664
+ if (bracketCount === 0) {
665
+ endIdx = i + 1
666
+ break
667
+ }
601
668
  }
602
669
  }
603
670
 
604
- // Try to find raw JSON array by counting brackets
605
- const startIdx = response.indexOf('[')
606
- if (startIdx !== -1) {
607
- let bracketCount = 0
608
- let endIdx = startIdx
609
- for (let i = startIdx; i < response.length; i++) {
610
- if (response[i] === '[') bracketCount++
611
- else if (response[i] === ']') {
612
- bracketCount--
613
- if (bracketCount === 0) {
614
- endIdx = i + 1
615
- break
671
+ if (endIdx > startIdx && bracketCount === 0) {
672
+ return text.slice(startIdx, endIdx)
673
+ }
674
+ return null
675
+ }
676
+
677
+ /** extractJsonPatch extracts a JSON Patch array from an LLM response. */
678
+ export function extractJsonPatch(response: string): JsonPatchOperation[] {
679
+ // Try to find JSON array in code blocks first
680
+ // Use indexOf to find code block boundaries to avoid ReDoS vulnerabilities
681
+ const codeBlockStart = response.indexOf('```')
682
+ if (codeBlockStart !== -1) {
683
+ const codeBlockEnd = response.indexOf('```', codeBlockStart + 3)
684
+ if (codeBlockEnd !== -1) {
685
+ const codeBlockContent = response.slice(codeBlockStart + 3, codeBlockEnd)
686
+ // Skip optional "json" language identifier and whitespace
687
+ const arrayStart = codeBlockContent.indexOf('[')
688
+ if (arrayStart !== -1) {
689
+ const arrayJson = extractBalancedArray(codeBlockContent, arrayStart)
690
+ if (arrayJson) {
691
+ try {
692
+ return JSON.parse(arrayJson) as JsonPatchOperation[]
693
+ } catch {
694
+ // Continue to try other methods
695
+ }
616
696
  }
617
697
  }
618
698
  }
699
+ }
619
700
 
620
- if (endIdx > startIdx) {
621
- try {
622
- return JSON.parse(response.slice(startIdx, endIdx)) as JsonPatchOperation[]
623
- } catch {
624
- // Fall through to empty array
625
- }
701
+ // Try to find raw JSON array by counting brackets
702
+ const arrayJson = extractBalancedArray(response, response.indexOf('['))
703
+ if (arrayJson) {
704
+ try {
705
+ return JSON.parse(arrayJson) as JsonPatchOperation[]
706
+ } catch {
707
+ // Fall through to empty array
626
708
  }
627
709
  }
628
710
 
629
- console.error(` Could not extract JSON Patch from response: ${response.slice(0, 100)}...`)
711
+ console.error(
712
+ ` Could not extract JSON Patch from response: ${response.slice(0, 100)}...`,
713
+ )
630
714
  return []
631
715
  }
632
716
 
@@ -649,7 +733,7 @@ export function applyJsonPatch(
649
733
  for (const op of operations) {
650
734
  const path = toJsonPointer(op.path)
651
735
  const pathParts = path.split('/').filter(Boolean)
652
-
736
+
653
737
  try {
654
738
  switch (op.op) {
655
739
  case 'add':
@@ -682,30 +766,81 @@ export function applyJsonPatch(
682
766
  const srcPath = toJsonPointer(op.from)
683
767
  const srcParts = srcPath.split('/').filter(Boolean)
684
768
  const srcValue = getValueAtPath(result, srcParts)
685
- setValueAtPath(result, pathParts, JSON.parse(JSON.stringify(srcValue)))
769
+ setValueAtPath(
770
+ result,
771
+ pathParts,
772
+ JSON.parse(JSON.stringify(srcValue)),
773
+ )
686
774
  break
687
775
  }
688
776
  case 'test': {
689
777
  // Test operation - verify value matches, throw if not
690
778
  const actualValue = getValueAtPath(result, pathParts)
691
779
  if (JSON.stringify(actualValue) !== JSON.stringify(op.value)) {
692
- console.error(` JSON Patch test failed: ${path} expected ${JSON.stringify(op.value)}, got ${JSON.stringify(actualValue)}`)
780
+ console.error(
781
+ ` JSON Patch test failed: ${path} expected ${JSON.stringify(op.value)}, got ${JSON.stringify(actualValue)}`,
782
+ )
693
783
  }
694
784
  break
695
785
  }
696
786
  }
697
787
  } catch (e) {
698
- console.error(` JSON Patch operation failed: ${JSON.stringify(op)} - ${e}`)
788
+ console.error(
789
+ ` JSON Patch operation failed: ${JSON.stringify(op)} - ${e}`,
790
+ )
699
791
  }
700
792
  }
701
793
 
702
794
  return result
703
795
  }
704
796
 
797
+ /** Keys that could be used for prototype pollution attacks. */
798
+ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
799
+
800
+ function isUnsafeKey(key: string): boolean {
801
+ return UNSAFE_KEYS.has(key)
802
+ }
803
+
804
+ /**
805
+ * Unescape a single JSON Pointer path segment according to RFC 6901.
806
+ * This ensures that checks for dangerous keys are applied to the
807
+ * effective property name, not the escaped form.
808
+ */
809
+ function unescapeJsonPointerSegment(segment: string): string {
810
+ return segment.replace(/~1/g, '/').replace(/~0/g, '~')
811
+ }
812
+
813
+ /**
814
+ * isSafePathSegment determines whether a JSON Pointer path segment is safe to use
815
+ * as a property key on an object. It rejects keys that are known to enable
816
+ * prototype pollution or that contain characters commonly used in special
817
+ * property notations.
818
+ */
819
+ function isSafePathSegment(segment: string): boolean {
820
+ // Normalize the segment as it will appear as a property key.
821
+ const normalized = unescapeJsonPointerSegment(String(segment))
822
+ if (isUnsafeKey(normalized)) return false
823
+ // Disallow bracket notation-style segments to avoid unexpected coercions.
824
+ if (normalized.includes('[') || normalized.includes(']')) return false
825
+ return true
826
+ }
827
+
705
828
  /** getValueAtPath retrieves a value at a JSON Pointer path. */
706
- function getValueAtPath(obj: Record<string, unknown>, parts: string[]): unknown {
829
+ function getValueAtPath(
830
+ obj: Record<string, unknown>,
831
+ parts: string[],
832
+ ): unknown {
707
833
  let current: unknown = obj
708
834
  for (const part of parts) {
835
+ // Block prototype-pollution: reject __proto__, constructor, prototype
836
+ if (
837
+ part === '__proto__' ||
838
+ part === 'constructor' ||
839
+ part === 'prototype'
840
+ ) {
841
+ return undefined
842
+ }
843
+ if (!isSafePathSegment(part)) return undefined
709
844
  if (current === null || current === undefined) return undefined
710
845
  if (Array.isArray(current)) {
711
846
  const idx = parseInt(part, 10)
@@ -720,46 +855,89 @@ function getValueAtPath(obj: Record<string, unknown>, parts: string[]): unknown
720
855
  }
721
856
 
722
857
  /** setValueAtPath sets a value at a JSON Pointer path. */
723
- function setValueAtPath(obj: Record<string, unknown>, parts: string[], value: unknown): void {
858
+ function setValueAtPath(
859
+ obj: Record<string, unknown>,
860
+ parts: string[],
861
+ value: unknown,
862
+ ): void {
724
863
  if (parts.length === 0) return
725
-
864
+
726
865
  let current: unknown = obj
727
866
  for (let i = 0; i < parts.length - 1; i++) {
728
867
  const part = parts[i]!
868
+ // Block prototype-pollution: reject __proto__, constructor, prototype
869
+ if (
870
+ part === '__proto__' ||
871
+ part === 'constructor' ||
872
+ part === 'prototype'
873
+ ) {
874
+ return
875
+ }
876
+ if (!isSafePathSegment(part)) {
877
+ // Avoid writing to dangerous or malformed prototype-related properties
878
+ return
879
+ }
729
880
  if (Array.isArray(current)) {
730
881
  const idx = parseInt(part, 10)
731
882
  if (current[idx] === undefined) {
732
883
  // Create intermediate object or array
733
884
  const nextPart = parts[i + 1]!
734
- current[idx] = /^\d+$/.test(nextPart) ? [] : {}
885
+ current[idx] = /^\d+$/.test(nextPart) ? [] : Object.create(null)
735
886
  }
736
887
  current = current[idx]
737
888
  } else if (typeof current === 'object' && current !== null) {
738
889
  const rec = current as Record<string, unknown>
739
890
  if (rec[part] === undefined) {
740
891
  const nextPart = parts[i + 1]!
741
- rec[part] = /^\d+$/.test(nextPart) ? [] : {}
892
+ rec[part] = /^\d+$/.test(nextPart) ? [] : Object.create(null)
742
893
  }
743
894
  current = rec[part]
744
895
  }
745
896
  }
746
897
 
747
898
  const lastPart = parts[parts.length - 1]!
899
+ // Block prototype-pollution: reject __proto__, constructor, prototype
900
+ if (
901
+ lastPart === '__proto__' ||
902
+ lastPart === 'constructor' ||
903
+ lastPart === 'prototype'
904
+ ) {
905
+ return
906
+ }
907
+ if (!isSafePathSegment(lastPart)) {
908
+ // Avoid writing to dangerous or malformed prototype-related properties
909
+ return
910
+ }
748
911
  if (Array.isArray(current)) {
749
912
  const idx = parseInt(lastPart, 10)
750
913
  current[idx] = value
751
914
  } else if (typeof current === 'object' && current !== null) {
752
- (current as Record<string, unknown>)[lastPart] = value
915
+ ;(current as Record<string, unknown>)[lastPart] = value
753
916
  }
754
917
  }
755
918
 
756
919
  /** removeValueAtPath removes a value at a JSON Pointer path. */
757
- function removeValueAtPath(obj: Record<string, unknown>, parts: string[]): void {
920
+ function removeValueAtPath(
921
+ obj: Record<string, unknown>,
922
+ parts: string[],
923
+ ): void {
758
924
  if (parts.length === 0) return
759
925
 
760
926
  let current: unknown = obj
761
927
  for (let i = 0; i < parts.length - 1; i++) {
762
928
  const part = parts[i]!
929
+ // Block prototype-pollution: reject __proto__, constructor, prototype
930
+ if (
931
+ part === '__proto__' ||
932
+ part === 'constructor' ||
933
+ part === 'prototype'
934
+ ) {
935
+ return
936
+ }
937
+ if (!isSafePathSegment(part)) {
938
+ // Avoid accessing dangerous prototype-related properties
939
+ return
940
+ }
763
941
  if (Array.isArray(current)) {
764
942
  current = current[parseInt(part, 10)]
765
943
  } else if (typeof current === 'object' && current !== null) {
@@ -770,6 +948,18 @@ function removeValueAtPath(obj: Record<string, unknown>, parts: string[]): void
770
948
  }
771
949
 
772
950
  const lastPart = parts[parts.length - 1]!
951
+ // Block prototype-pollution: reject __proto__, constructor, prototype
952
+ if (
953
+ lastPart === '__proto__' ||
954
+ lastPart === 'constructor' ||
955
+ lastPart === 'prototype'
956
+ ) {
957
+ return
958
+ }
959
+ if (!isSafePathSegment(lastPart)) {
960
+ // Avoid deleting dangerous or malformed properties
961
+ return
962
+ }
773
963
  if (Array.isArray(current)) {
774
964
  const idx = parseInt(lastPart, 10)
775
965
  current.splice(idx, 1)
@@ -805,8 +995,15 @@ export function parseJson<T>(
805
995
  const zodResult = zodSchema.safeParse(result.json)
806
996
 
807
997
  if (!zodResult.success) {
808
- const issues = zodResult.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')
809
- throw new ValidationError(`Output validation failed: ${issues}`, response, zodResult.error, errors)
998
+ const issues = zodResult.error.issues
999
+ .map((i) => `${i.path.join('.')}: ${i.message}`)
1000
+ .join('; ')
1001
+ throw new ValidationError(
1002
+ `Output validation failed: ${issues}`,
1003
+ response,
1004
+ zodResult.error,
1005
+ errors,
1006
+ )
810
1007
  }
811
1008
 
812
1009
  // Shouldn't reach here
package/src/pipeline.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * DSTS SDK Pipeline orchestrator.
2
+ * ocpipe Pipeline orchestrator.
3
3
  *
4
4
  * Manages execution context, state, checkpointing, logging, retry logic, and sub-pipelines.
5
5
  */
@@ -58,7 +58,8 @@ export class Pipeline<S extends BaseState> {
58
58
 
59
59
  const startTime = Date.now()
60
60
  let lastError: Error | undefined
61
- const retryConfig = options?.retry ?? this.config.retry ?? { maxAttempts: 1 }
61
+ const retryConfig = options?.retry ??
62
+ this.config.retry ?? { maxAttempts: 1 }
62
63
 
63
64
  for (let attempt = 1; attempt <= retryConfig.maxAttempts; attempt++) {
64
65
  try {
@@ -176,7 +177,7 @@ export class Pipeline<S extends BaseState> {
176
177
  sessionId: string,
177
178
  ): Promise<Pipeline<S> | null> {
178
179
  const path = `${config.checkpointDir}/${config.name}_${sessionId}.json`
179
-
180
+
180
181
  try {
181
182
  const content = await readFile(path, 'utf-8')
182
183
  const state = JSON.parse(content) as S