Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions packages/engine/src/__tests__/security/path-containment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest'
import { assertWithinProject } from '../../security/path-containment.js'

describe('assertWithinProject', () => {
const projectRoot = '/home/user/project'

it('allows a path within the project root', () => {
expect(() => assertWithinProject('src/main.ts', projectRoot)).not.toThrow()
})

it('allows a nested path within the project root', () => {
expect(() => assertWithinProject('src/rules/order.rules.yaml', projectRoot)).not.toThrow()
})

it('throws on ../../etc/passwd style traversal', () => {
expect(() => assertWithinProject('../../etc/passwd', projectRoot)).toThrow(
/resolves outside project root/,
)
})

it('throws on absolute path outside project', () => {
expect(() => assertWithinProject('/etc/passwd', projectRoot)).toThrow(
/resolves outside project root/,
)
})

it('error message shows relative path, not absolute', () => {
try {
assertWithinProject('../../etc/passwd', projectRoot)
expect.fail('should have thrown')
} catch (err) {
const message = (err as Error).message
expect(message).toContain('../../etc/passwd')
expect(message).not.toContain(projectRoot)
}
})

it('throws on path that starts with projectRoot as prefix but is not a child', () => {
// /home/user/project-extra is not inside /home/user/project
expect(() => assertWithinProject('../project-extra/file', projectRoot)).toThrow(
/resolves outside project root/,
)
})

it('allows the project root itself', () => {
expect(() => assertWithinProject('.', projectRoot)).not.toThrow()
})

it('throws on sneaky path with encoded traversal', () => {
expect(() => assertWithinProject('src/../../../etc/shadow', projectRoot)).toThrow(
/resolves outside project root/,
)
})
})
87 changes: 87 additions & 0 deletions packages/engine/src/__tests__/security/reserved-node-ids.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest'
import { validate } from '@ruminaider/flowprint-schema'

function makeDoc(nodeId: string) {
return {
schema: 'flowprint/1.0',
name: 'test',
version: '1.0.0',
lanes: { main: { label: 'Main', visibility: 'external', order: 0 } },
nodes: {
[nodeId]: { type: 'action', lane: 'main', label: 'Step', next: 'end' },
end: { type: 'terminal', lane: 'main', label: 'End', outcome: 'success' },
},
}
}

describe('reserved node IDs', () => {
it('rejects node ID "input"', () => {
const result = validate(makeDoc('input'))
expect(result.valid).toBe(false)
expect(
result.errors.some(
(e) => e.path === '/nodes/input' && e.message.includes('reserved'),
),
).toBe(true)
})

it('rejects node ID "state"', () => {
const result = validate(makeDoc('state'))
expect(result.valid).toBe(false)
expect(
result.errors.some(
(e) => e.path === '/nodes/state' && e.message.includes('reserved'),
),
).toBe(true)
})

it('rejects node ID "Math"', () => {
const result = validate(makeDoc('Math'))
expect(result.valid).toBe(false)
expect(
result.errors.some(
(e) => e.path === '/nodes/Math' && e.message.includes('reserved'),
),
).toBe(true)
})

it('rejects node ID "node"', () => {
const result = validate(makeDoc('node'))
expect(result.valid).toBe(false)
expect(
result.errors.some(
(e) => e.path === '/nodes/node' && e.message.includes('reserved'),
),
).toBe(true)
})

it('rejects node ID "output"', () => {
const result = validate(makeDoc('output'))
expect(result.valid).toBe(false)
expect(
result.errors.some(
(e) => e.path === '/nodes/output' && e.message.includes('reserved'),
),
).toBe(true)
})

it('allows non-reserved node ID "my_action"', () => {
const result = validate(makeDoc('my_action'))
expect(result.valid).toBe(true)
})

it('allows non-reserved node ID "process_input"', () => {
const result = validate(makeDoc('process_input'))
expect(result.valid).toBe(true)
})

it('error message lists all reserved IDs', () => {
const result = validate(makeDoc('input'))
const error = result.errors.find((e) => e.path === '/nodes/input')
expect(error?.message).toContain('input')
expect(error?.message).toContain('state')
expect(error?.message).toContain('Math')
expect(error?.message).toContain('node')
expect(error?.message).toContain('output')
})
})
56 changes: 56 additions & 0 deletions packages/engine/src/__tests__/security/yaml-limits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { parse } from 'yaml'

describe('YAML parsing limits', () => {
it('rejects YAML with excessive aliases (YAML bomb protection)', () => {
// Build a YAML bomb: define an anchor and reference it 101+ times
const lines = ['top: &a value']
for (let i = 0; i < 101; i++) {
lines.push(`k${String(i)}: *a`)
}
const yaml = lines.join('\n')

// With maxAliasCount: 100, parsing should throw
expect(() => parse(yaml, { maxAliasCount: 100, schema: 'core' })).toThrow(
/excessive alias count/i,
)
})

it('accepts YAML with aliases within the limit', () => {
const lines = ['top: &a value']
for (let i = 0; i < 50; i++) {
lines.push(`k${String(i)}: *a`)
}
const yaml = lines.join('\n')

// With maxAliasCount: 100, parsing 50 aliases should succeed
const result = parse(yaml, { maxAliasCount: 100, schema: 'core' })
expect(result).toBeDefined()
expect(result.top).toBe('value')
expect(result.k0).toBe('value')
})

it('verifies loadRulesFile rejects YAML bombs', async () => {
// Dynamically import to test the actual code path with mocked fs
vi.mock('node:fs', () => ({
readFileSync: vi.fn(),
}))

const { readFileSync } = await import('node:fs')
const mockedReadFileSync = vi.mocked(readFileSync)
const { loadRulesFile } = await import('../../rules/evaluator.js')

// Build a YAML bomb with 101 aliases
const lines = ['schema: flowprint-rules/1.0', 'name: bomb', 'hit_policy: first', 'x: &a val']
for (let i = 0; i < 101; i++) {
lines.push(`k${String(i)}: *a`)
}
lines.push('rules:', ' - then:', ' result: true')

mockedReadFileSync.mockReturnValue(lines.join('\n'))

expect(() => loadRulesFile('bomb.yaml', '/project')).toThrow(/parse/i)

vi.restoreAllMocks()
})
})
3 changes: 3 additions & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ export type {
RulesTestResult,
} from './rules/index.js'

// Security
export { assertWithinProject } from './security/index.js'
Comment on lines +37 to +38
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR adds a new public export assertWithinProject and schema structural changes but doesn't include a .changeset/*.md — should we add one?

Finding type: AI Coding Guidelines | Severity: 🟠 Medium


Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents:

In packages/engine/src/index.ts around lines 37-38, a new public export
`assertWithinProject` was added. Also account for structural validation changes in
packages/schema/src/structural.ts that alter package behavior. Add a .changeset/*.md
file that (1) describes the addition of the new public export and the structural
validation change, (2) lists the affected packages (at least packages/engine and
packages/schema), and (3) specifies appropriate version bumps (e.g., minor for the new
public API export and patch or minor as appropriate for the schema behavioral change, or
mark as breaking if the structural change is breaking). Ensure the changeset includes a
short human-readable summary and author name so the release tooling will include these
changes before merging.

Heads up!

Your free trial ends tomorrow.
To keep getting your PRs reviewed by Baz, update your team's subscription


// Codegen
export { generateCode } from './codegen/index.js'
export type { GenerateResult, GenerateOptions, GeneratedFile } from './codegen/index.js'
4 changes: 3 additions & 1 deletion packages/engine/src/rules/evaluator.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { parse } from 'yaml'
import { assertWithinProject } from '../security/index.js'
import { validateRules } from '@ruminaider/flowprint-schema'
import type { ExecutionContext } from '../runner/types.js'
import { evaluateExpression } from '../runner/evaluator.js'
Expand All @@ -24,6 +25,7 @@ import type {
* @returns Parsed and validated RulesDocument
*/
export function loadRulesFile(filePath: string, projectRoot: string): RulesDocument {
assertWithinProject(filePath, projectRoot)
const absolutePath = resolve(projectRoot, filePath)

let content: string
Expand All @@ -36,7 +38,7 @@ export function loadRulesFile(filePath: string, projectRoot: string): RulesDocum

let doc: unknown
try {
doc = parse(content)
doc = parse(content, { maxAliasCount: 100, schema: 'core' })
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
throw new Error(`Failed to parse rules file "${filePath}": ${message}`)
Expand Down
2 changes: 2 additions & 0 deletions packages/engine/src/runner/loader.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { resolve } from 'node:path'
import { assertWithinProject } from '../security/index.js'

/**
* Dynamically import an entry point file and extract the named symbol.
Expand All @@ -11,6 +12,7 @@ export async function loadEntryPoint(
entry: { file: string; symbol: string },
projectRoot: string,
): Promise<(...args: unknown[]) => unknown> {
assertWithinProject(entry.file, projectRoot)
const filePath = resolve(projectRoot, entry.file)

let mod: Record<string, unknown>
Expand Down
1 change: 1 addition & 0 deletions packages/engine/src/security/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { assertWithinProject } from './path-containment.js'
16 changes: 16 additions & 0 deletions packages/engine/src/security/path-containment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { resolve, sep, relative } from 'node:path'

/**
* Assert that a file path resolves within the project root directory.
* Prevents path traversal attacks (e.g. `../../etc/passwd`).
*
* @param filePath - The file path to validate (absolute or relative)
* @param projectRoot - The root directory boundary
* @throws Error if the resolved path is outside the project root
*/
export function assertWithinProject(filePath: string, projectRoot: string): void {
const resolved = resolve(projectRoot, filePath)
if (!resolved.startsWith(projectRoot + sep) && resolved !== projectRoot) {
throw new Error(`Path "${relative(projectRoot, resolved)}" resolves outside project root`)
Comment on lines +11 to +14
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assertWithinProject compares the resolved path to an unnormalized projectRoot — should we resolve projectRoot first

const normalizedRoot = resolve(projectRoot)
resolved.startsWith(normalizedRoot + sep)

Finding type: Breaking Changes | Severity: 🔴 High


Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents:

In packages/engine/src/security/path-containment.ts around lines 11 to 14, the
assertWithinProject function resolves filePath against the raw projectRoot but compares
the resolved path to projectRoot + sep, which breaks when projectRoot is a relative path
or has symlinks. Refactor by first normalizing/resolving projectRoot (e.g. const
normalizedRoot = resolve(projectRoot)), then use normalizedRoot in the prefix/equality
checks and in the relative() call for the error message (keep the same
startsWith(normalizedRoot + sep) && resolved !== normalizedRoot logic). Ensure imports
stay the same and update the thrown message to use relative(normalizedRoot, resolved).

Heads up!

Your free trial ends tomorrow.
To keep getting your PRs reviewed by Baz, update your team's subscription

Comment on lines +11 to +14
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assertWithinProject compares a resolved path to the raw projectRoot so relative/unnormalized roots can fail; should we normalize projectRoot with resolve or realpath before the startsWith/equals check?

Finding type: Logical Bugs | Severity: 🔴 High


Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents:

In packages/engine/src/security/path-containment.ts around lines 11 to 14, the
assertWithinProject function compares the resolved file path against the raw projectRoot
string which fails for relative or unnormalized roots. Refactor by normalizing the
boundary first (e.g. const normalizedRoot = resolve(projectRoot) or use
realpathSync(projectRoot) if you need symlink resolution), then compute const resolved =
resolve(normalizedRoot, filePath) and perform the containment check against
normalizedRoot (use resolved.startsWith(normalizedRoot + sep) || resolved ===
normalizedRoot). Update variable names accordingly and keep the existing error message
logic.

Heads up!

Your free trial ends tomorrow.
To keep getting your PRs reviewed by Baz, update your team's subscription

Comment on lines +11 to +14
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assertWithinProject concatenates projectRoot + sep without normalizing, so a trailing slash can produce a double separator and break the .startsWith containment check; should we normalize projectRoot first (e.g. const normalizedRoot = resolve(projectRoot)) and use normalizedRoot + sep?

Finding type: Logical Bugs | Severity: 🔴 High


Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents:

In packages/engine/src/security/path-containment.ts around lines 11-14, the
assertWithinProject function uses projectRoot + sep as the containment prefix without
normalizing projectRoot, which fails when projectRoot has a trailing separator. Refactor
by first normalizing the root (e.g. const normalizedRoot = resolve(projectRoot)), use
normalizedRoot when resolving the file path and for the prefix (normalizedRoot + sep),
and update the equality/startsWith checks and the error message to use normalizedRoot
and relative(normalizedRoot, resolved). Ensure behavior remains the same when resolved
=== normalizedRoot.

Heads up!

Your free trial ends tomorrow.
To keep getting your PRs reviewed by Baz, update your team's subscription

}
}
25 changes: 22 additions & 3 deletions packages/schema/src/structural.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import type { ValidationError } from './types.js'

/**
* Node IDs that conflict with expression sandbox globals.
* Using these as node IDs would shadow built-in variables in the
* expression evaluator, leading to subtle bugs or security issues.
*/
const RESERVED_NODE_IDS = new Set(['input', 'state', 'Math', 'node', 'output'])
Comment on lines 1 to +8
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RESERVED_NODE_IDS rejects names like state, node, and output that the evaluator sandbox doesn't expose — should we derive the reserved list from the sandbox globals or export a shared literal?

Finding type: Breaking Changes | Severity: 🔴 High


Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents:

In packages/schema/src/structural.ts around lines 1 to 8, the new hardcoded
RESERVED_NODE_IDS set incorrectly blocks names (state, node, output) that are not
actually injected into the evaluator sandbox. Replace the hardcoded Set with a single
source of truth: either import the reserved/global names from the evaluator module
(packages/engine/src/runner/evaluator.ts) or export a shared constant from a new/common
module that both the engine and schema packages consume. Update the top of this file to
import that shared list and use it to build RESERVED_NODE_IDS, and add a short comment
explaining that this list must match the evaluator sandbox globals to avoid breaking
changes. Ensure tests or a validation run still pass.

Heads up!

Your free trial ends tomorrow.
To keep getting your PRs reviewed by Baz, update your team's subscription


/**
* Perform structural validation on a schema-valid Flowprint document.
* Checks for:
* 1. Dangling node references (next, cases[].next, branches[], join, error.catch, default, timeout_next)
* 2. Invalid lane references (node.lane must exist in lanes)
* 3. Orphan nodes (non-terminals: no incoming AND no outgoing; terminals: no incoming)
* 1. Reserved node IDs (conflict with expression sandbox globals)
* 2. Dangling node references (next, cases[].next, branches[], join, error.catch, default, timeout_next)
* 3. Invalid lane references (node.lane must exist in lanes)
* 4. Orphan nodes (non-terminals: no incoming AND no outgoing; terminals: no incoming)
*
* This function assumes the document has already passed schema validation.
*/
Expand All @@ -19,6 +27,17 @@ export function validateStructure(doc: Record<string, unknown>): ValidationError
return errors
}

// Check for reserved node IDs
for (const nodeId of Object.keys(nodes)) {
if (RESERVED_NODE_IDS.has(nodeId)) {
errors.push({
path: `/nodes/${nodeId}`,
message: `Node ID "${nodeId}" is reserved (conflicts with expression sandbox globals). Reserved IDs: ${[...RESERVED_NODE_IDS].join(', ')}`,
severity: 'error',
})
}
}

const laneIds = new Set(Object.keys(lanes))
const nodeIds = new Set(Object.keys(nodes))

Expand Down
Loading