Skip to content

Commit 45fd3fa

Browse files
Merge pull request #4 from cueapi/security/wave-2
security: wave 2 fixes (branch protection + code bugs + hygiene)
2 parents 5dd3ce0 + 2d40f26 commit 45fd3fa

8 files changed

Lines changed: 129 additions & 5 deletions

File tree

.github/CODEOWNERS

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Require review from @govindkavaturi-art for critical paths
2+
src/* @govindkavaturi-art
3+
.github/* @govindkavaturi-art
4+
package.json @govindkavaturi-art
5+
tsconfig.json @govindkavaturi-art

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@ dist/
33
*.tsbuildinfo
44
.DS_Store
55
coverage/
6+
.npmrc
7+
.env*

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,33 @@ const desc = myPipeline.describe()
208208

209209
Schemas are serialized as JSON Schema via `zod-to-json-schema`.
210210

211+
## Handling Failures Safely
212+
213+
When a step fails, the `Failure` object contains the raw `input`, `output`, and `error.message` from the step. This is useful for debugging but may contain sensitive data if your step processes PII, API keys, or other confidential information.
214+
215+
**Before exposing failures to end users, logs, or monitoring systems, sanitize the failure object:**
216+
217+
```typescript
218+
const result = await myPipeline.run(input)
219+
220+
if (!result.ok) {
221+
// Internal logging — full context
222+
logger.debug('Pipeline failure', result.failure)
223+
224+
// User-facing — redact raw data
225+
const safeError = {
226+
step: result.failure.step,
227+
reason: result.failure.reason,
228+
type: result.failure.type,
229+
attempts: result.failure.attempts,
230+
// Omit: input, output (may contain sensitive data)
231+
}
232+
return { error: safeError }
233+
}
234+
```
235+
236+
A pipeline-level `onFailure` redaction hook is planned for v0.2.
237+
211238
## Cuechain + CueAPI
212239

213240
Cuechain verifies contracts between steps. [CueAPI](https://cueapi.ai) verifies outcomes against reality. Use one, use both.

docs/roadmap.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Roadmap
2+
3+
## v0.2
4+
5+
- **`onFailure` redaction hook**: Pipeline-level callback to sanitize `Failure` objects before they leave the pipeline boundary. Allows callers to strip PII, API keys, or other sensitive data from `input`, `output`, and `reason` fields without manual post-processing.

src/runner.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,16 @@ function formatZodError(error: ZodError): string {
2020
function runGates(
2121
gates: Gate<unknown>[],
2222
output: unknown,
23-
): { reason: string; context?: unknown } | null {
23+
): { reason: string; context?: unknown; thrown?: boolean } | null {
2424
for (const gate of gates) {
25-
const result = gate(output)
26-
if (!result.ok) {
27-
return { reason: result.reason, context: result.context }
25+
try {
26+
const result = gate(output)
27+
if (!result.ok) {
28+
return { reason: result.reason, context: result.context }
29+
}
30+
} catch (error) {
31+
const reason = error instanceof Error ? error.message : String(error)
32+
return { reason: `Gate threw: ${reason}`, thrown: true }
2833
}
2934
}
3035
return null

src/step.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,23 @@ import type { Step, StepConfig } from './types.js'
2626
export function defineStep<TInput, TOutput>(
2727
config: StepConfig<TInput, TOutput>,
2828
): Step<TInput, TOutput> {
29+
const maxAttempts = config.retry?.maxAttempts ?? 1
30+
31+
if (maxAttempts < 1 || maxAttempts > 20) {
32+
throw new RangeError(`maxAttempts must be between 1 and 20 (got ${maxAttempts})`)
33+
}
34+
35+
if (!Number.isInteger(maxAttempts)) {
36+
throw new RangeError(`maxAttempts must be an integer (got ${maxAttempts})`)
37+
}
38+
2939
return {
3040
name: config.name,
3141
inputSchema: config.input as z.ZodType<TInput>,
3242
outputSchema: config.output as z.ZodType<TOutput>,
3343
gates: config.gates ?? [],
3444
retry: {
35-
maxAttempts: config.retry?.maxAttempts ?? 1,
45+
maxAttempts,
3646
on: config.retry?.on ?? ['schema', 'gate'],
3747
},
3848
run: config.run,

tests/gates.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,4 +86,27 @@ describe('quality gates', () => {
8686
expect(result.failure.reason).toContain('expected 3 items, got 2')
8787
}
8888
})
89+
90+
it('attributes thrown gate exceptions as type gate', async () => {
91+
const step = defineStep({
92+
name: 'throwing-gate',
93+
input: z.object({ x: z.number() }),
94+
output: z.object({ y: z.number() }),
95+
gates: [
96+
() => {
97+
throw new Error('gate exploded')
98+
},
99+
],
100+
run: async (input) => ({ y: input.x * 2 }),
101+
})
102+
103+
const p = pipeline('throw-gate').step(step)
104+
const result = await p.run({ x: 5 })
105+
106+
expect(result.ok).toBe(false)
107+
if (!result.ok) {
108+
expect(result.failure.type).toBe('gate')
109+
expect(result.failure.reason).toContain('Gate threw: gate exploded')
110+
}
111+
})
89112
})

tests/retry.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,4 +165,51 @@ describe('retry with failure context', () => {
165165
expect(result.ok).toBe(false)
166166
expect(attempts).toBe(1)
167167
})
168+
169+
it('throws RangeError when maxAttempts is 0', () => {
170+
expect(() =>
171+
defineStep({
172+
name: 'bad-retry',
173+
input: z.object({ x: z.number() }),
174+
output: z.object({ y: z.number() }),
175+
retry: { maxAttempts: 0 },
176+
run: async (input) => ({ y: input.x }),
177+
}),
178+
).toThrow(RangeError)
179+
})
180+
181+
it('throws RangeError when maxAttempts exceeds 20', () => {
182+
expect(() =>
183+
defineStep({
184+
name: 'bad-retry',
185+
input: z.object({ x: z.number() }),
186+
output: z.object({ y: z.number() }),
187+
retry: { maxAttempts: 21 },
188+
run: async (input) => ({ y: input.x }),
189+
}),
190+
).toThrow(RangeError)
191+
})
192+
193+
it('throws RangeError when maxAttempts is not an integer', () => {
194+
expect(() =>
195+
defineStep({
196+
name: 'bad-retry',
197+
input: z.object({ x: z.number() }),
198+
output: z.object({ y: z.number() }),
199+
retry: { maxAttempts: 2.5 },
200+
run: async (input) => ({ y: input.x }),
201+
}),
202+
).toThrow(RangeError)
203+
})
204+
205+
it('accepts maxAttempts of 20 (upper bound)', async () => {
206+
const step = defineStep({
207+
name: 'max-retry',
208+
input: z.object({ x: z.number() }),
209+
output: z.object({ y: z.number() }),
210+
retry: { maxAttempts: 20 },
211+
run: async (input) => ({ y: input.x }),
212+
})
213+
expect(step.retry.maxAttempts).toBe(20)
214+
})
168215
})

0 commit comments

Comments
 (0)