-
Notifications
You must be signed in to change notification settings - Fork 3.3k
fix(execution): scope X-Sim-Via header to internal routes and enforce depth limit #3313
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+247
−3
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
29189e4
feat(execution): workflow cycle detection via X-Sim-Via header
waleedlatif1 6022b12
fix(execution): scope X-Sim-Via header to internal routes and add chi…
waleedlatif1 27e9560
fix(execution): validate child call chain instead of parent chain
waleedlatif1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| /** | ||
| * @vitest-environment node | ||
| */ | ||
| import { describe, expect, it } from 'vitest' | ||
| import { | ||
| buildNextCallChain, | ||
| MAX_CALL_CHAIN_DEPTH, | ||
| parseCallChain, | ||
| SIM_VIA_HEADER, | ||
| serializeCallChain, | ||
| validateCallChain, | ||
| } from '@/lib/execution/call-chain' | ||
|
|
||
| describe('call-chain', () => { | ||
| describe('SIM_VIA_HEADER', () => { | ||
| it('has the expected header name', () => { | ||
| expect(SIM_VIA_HEADER).toBe('X-Sim-Via') | ||
| }) | ||
| }) | ||
|
|
||
| describe('MAX_CALL_CHAIN_DEPTH', () => { | ||
| it('equals 10', () => { | ||
| expect(MAX_CALL_CHAIN_DEPTH).toBe(10) | ||
| }) | ||
| }) | ||
|
|
||
| describe('parseCallChain', () => { | ||
| it('returns empty array for null', () => { | ||
| expect(parseCallChain(null)).toEqual([]) | ||
| }) | ||
|
|
||
| it('returns empty array for undefined', () => { | ||
| expect(parseCallChain(undefined)).toEqual([]) | ||
| }) | ||
|
|
||
| it('returns empty array for empty string', () => { | ||
| expect(parseCallChain('')).toEqual([]) | ||
| }) | ||
|
|
||
| it('returns empty array for whitespace-only string', () => { | ||
| expect(parseCallChain(' ')).toEqual([]) | ||
| }) | ||
|
|
||
| it('parses a single workflow ID', () => { | ||
| expect(parseCallChain('wf-abc')).toEqual(['wf-abc']) | ||
| }) | ||
|
|
||
| it('parses multiple comma-separated workflow IDs', () => { | ||
| expect(parseCallChain('wf-a,wf-b,wf-c')).toEqual(['wf-a', 'wf-b', 'wf-c']) | ||
| }) | ||
|
|
||
| it('trims whitespace around workflow IDs', () => { | ||
| expect(parseCallChain(' wf-a , wf-b , wf-c ')).toEqual(['wf-a', 'wf-b', 'wf-c']) | ||
| }) | ||
|
|
||
| it('filters out empty segments', () => { | ||
| expect(parseCallChain('wf-a,,wf-b')).toEqual(['wf-a', 'wf-b']) | ||
| }) | ||
| }) | ||
|
|
||
| describe('serializeCallChain', () => { | ||
| it('serializes an empty array', () => { | ||
| expect(serializeCallChain([])).toBe('') | ||
| }) | ||
|
|
||
| it('serializes a single ID', () => { | ||
| expect(serializeCallChain(['wf-a'])).toBe('wf-a') | ||
| }) | ||
|
|
||
| it('serializes multiple IDs with commas', () => { | ||
| expect(serializeCallChain(['wf-a', 'wf-b', 'wf-c'])).toBe('wf-a,wf-b,wf-c') | ||
| }) | ||
| }) | ||
|
|
||
| describe('validateCallChain', () => { | ||
| it('returns null for an empty chain', () => { | ||
| expect(validateCallChain([])).toBeNull() | ||
| }) | ||
|
|
||
| it('returns null when chain is under max depth', () => { | ||
| expect(validateCallChain(['wf-a', 'wf-b'])).toBeNull() | ||
| }) | ||
|
|
||
| it('allows legitimate self-recursion', () => { | ||
| expect(validateCallChain(['wf-a', 'wf-a', 'wf-a'])).toBeNull() | ||
| }) | ||
|
|
||
| it('returns depth error when chain is at max depth', () => { | ||
| const chain = Array.from({ length: MAX_CALL_CHAIN_DEPTH }, (_, i) => `wf-${i}`) | ||
| const error = validateCallChain(chain) | ||
| expect(error).toContain( | ||
| `Maximum workflow call chain depth (${MAX_CALL_CHAIN_DEPTH}) exceeded` | ||
| ) | ||
| }) | ||
|
|
||
| it('allows chain just under max depth', () => { | ||
| const chain = Array.from({ length: MAX_CALL_CHAIN_DEPTH - 1 }, (_, i) => `wf-${i}`) | ||
| expect(validateCallChain(chain)).toBeNull() | ||
| }) | ||
| }) | ||
|
|
||
| describe('buildNextCallChain', () => { | ||
| it('appends workflow ID to empty chain', () => { | ||
| expect(buildNextCallChain([], 'wf-a')).toEqual(['wf-a']) | ||
| }) | ||
|
|
||
| it('appends workflow ID to existing chain', () => { | ||
| expect(buildNextCallChain(['wf-a', 'wf-b'], 'wf-c')).toEqual(['wf-a', 'wf-b', 'wf-c']) | ||
| }) | ||
|
|
||
| it('does not mutate the original chain', () => { | ||
| const original = ['wf-a'] | ||
| const result = buildNextCallChain(original, 'wf-b') | ||
| expect(original).toEqual(['wf-a']) | ||
| expect(result).toEqual(['wf-a', 'wf-b']) | ||
| }) | ||
| }) | ||
|
|
||
| describe('round-trip', () => { | ||
| it('parse → serialize is identity', () => { | ||
| const header = 'wf-a,wf-b,wf-c' | ||
| expect(serializeCallChain(parseCallChain(header))).toBe(header) | ||
| }) | ||
|
|
||
| it('serialize → parse is identity', () => { | ||
| const chain = ['wf-a', 'wf-b', 'wf-c'] | ||
| expect(parseCallChain(serializeCallChain(chain))).toEqual(chain) | ||
| }) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| /** | ||
| * Workflow call chain detection using the Via-style pattern. | ||
| * | ||
| * Prevents infinite execution loops when workflows call each other via API or | ||
| * MCP endpoints. Each hop appends the current workflow ID to the `X-Sim-Via` | ||
| * header; on ingress the chain is checked for depth. | ||
| */ | ||
|
|
||
| export const SIM_VIA_HEADER = 'X-Sim-Via' | ||
| export const MAX_CALL_CHAIN_DEPTH = 10 | ||
|
|
||
| /** | ||
| * Parses the `X-Sim-Via` header value into an ordered list of workflow IDs. | ||
| * Returns an empty array when the header is absent or empty. | ||
| */ | ||
| export function parseCallChain(headerValue: string | null | undefined): string[] { | ||
| if (!headerValue || !headerValue.trim()) { | ||
| return [] | ||
| } | ||
| return headerValue | ||
| .split(',') | ||
| .map((id) => id.trim()) | ||
| .filter(Boolean) | ||
| } | ||
|
|
||
| /** | ||
| * Serializes a call chain array back into the header value format. | ||
| */ | ||
| export function serializeCallChain(chain: string[]): string { | ||
| return chain.join(',') | ||
| } | ||
|
|
||
| /** | ||
| * Validates that the call chain has not exceeded the maximum depth. | ||
| * Returns an error message string if invalid, or `null` if the chain is | ||
| * safe to extend. | ||
| */ | ||
| export function validateCallChain(chain: string[]): string | null { | ||
| if (chain.length >= MAX_CALL_CHAIN_DEPTH) { | ||
| return `Maximum workflow call chain depth (${MAX_CALL_CHAIN_DEPTH}) exceeded.` | ||
| } | ||
|
|
||
| return null | ||
| } | ||
|
|
||
| /** | ||
| * Builds the next call chain by appending the current workflow ID. | ||
| */ | ||
| export function buildNextCallChain(chain: string[], workflowId: string): string[] { | ||
| return [...chain, workflowId] | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.