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/DESIGN.md +257 -0
- package/GETTING_STARTED.md +384 -0
- package/README.md +37 -38
- package/example/ckpt/hello-world_20251227_044217.json +27 -0
- package/example/correction.ts +21 -7
- package/example/index.ts +1 -1
- package/llms.txt +200 -0
- package/package.json +18 -2
- package/src/agent.ts +46 -21
- package/src/index.ts +2 -2
- package/src/module.ts +21 -11
- package/src/parsing.ts +273 -76
- package/src/pipeline.ts +4 -3
- package/src/predict.ts +62 -24
- package/src/signature.ts +1 -1
- package/src/state.ts +1 -1
- package/src/testing.ts +23 -14
- package/src/types.ts +19 -11
package/src/parsing.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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: [
|
|
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: [
|
|
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 =
|
|
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 (
|
|
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(
|
|
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 = (
|
|
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 = (
|
|
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 = (
|
|
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 = (
|
|
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 } })
|
|
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(
|
|
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(
|
|
388
|
+
lines.push(
|
|
389
|
+
`${i + 1}. Field "${error.path}": ${error.message} (expected: ${error.expectedType})`,
|
|
390
|
+
)
|
|
358
391
|
if (error.foundField) {
|
|
359
|
-
lines.push(
|
|
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(
|
|
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(
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
/\$/,
|
|
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(
|
|
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(
|
|
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(
|
|
551
|
-
|
|
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(
|
|
623
|
+
lines.push(
|
|
624
|
+
`${i + 1}. Field "${error.path}": ${error.message} (expected: ${error.expectedType})`,
|
|
625
|
+
)
|
|
571
626
|
if (error.foundField) {
|
|
572
|
-
lines.push(
|
|
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(
|
|
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
|
-
/**
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
809
|
-
|
|
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
|
-
*
|
|
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 ??
|
|
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
|