/** * Deterministic parser for workflow scripts. * * The workflow entry statement must be a literal `export const meta = { ... }`. * Runtime execution intentionally happens elsewhere; this parser only accepts a * JSON-like JavaScript object literal for metadata so metadata is side-effect free. * * Concepts adapted from michaelliv/pi-dynamic-workflows (MIT): literal metadata * gate before workflow execution and deterministic metadata parsing constraints. */ import type { ParsedWorkflow, WorkflowJsonValue, WorkflowMeta, } from "./workflow-types.js"; const META_PREFIX_RE = /^export\s+const\s+meta\s*=\s*/; const UNICODE_ESCAPE_RE = /^[\da-fA-F]{4}$/; const NUMBER_RE = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/; const WHITESPACE_RE = /\s/; const LEADING_WHITESPACE_RE = /^\s*/; const STATEMENT_END_RE = /^\s*;?/; const IDENTIFIER_START_RE = /^[A-Za-z_$]$/; const IDENTIFIER_PART_RE = /^[\w$]$/; interface ForbiddenPattern { pattern: RegExp; message: string; } const FORBIDDEN_RUNTIME_PATTERNS: ForbiddenPattern[] = [ { pattern: /\bimport\s*(?:\(|[\s{*"'])/, message: "Workflow scripts cannot import modules.", }, { pattern: /\bexport\s+/, message: "Workflow scripts can only export the first literal meta statement.", }, { pattern: /\brequire\s*\(/, message: "Workflow scripts cannot call require().", }, { pattern: /\bDate\s*(?:\?\.|\.)\s*now\s*(?:\?\.\s*)?\(/, message: "Workflow scripts cannot call Date.now().", }, { pattern: /\bnew\s+Date\b/, message: "Workflow scripts cannot construct Date values.", }, { pattern: /\bMath\s*(?:\?\.|\.)\s*random\s*(?:\?\.\s*)?\(/, message: "Workflow scripts cannot call Math.random().", }, ]; class WorkflowParseError extends Error { readonly index?: number; constructor(message: string, index?: number) { super(index === undefined ? message : `${message} (at ${index})`); this.name = "WorkflowParseError"; this.index = index; } } class LiteralParser { private readonly input: string; private index = 0; constructor(input: string) { this.input = input; } parse(): WorkflowJsonValue { const value = this.parseValue(); this.skipTrivia(); if (!this.isDone()) { throw this.error("Unexpected token in workflow meta literal"); } return value; } private parseValue(): WorkflowJsonValue { this.skipTrivia(); const char = this.peek(); if (char === "{") { return this.parseObject(); } if (char === "[") { return this.parseArray(); } if (char === '"' || char === "'") { return this.parseString(); } if (char === "`") { throw this.error("Template literals are not allowed in workflow meta."); } if (char === "-" || isDigit(char)) { return this.parseNumber(); } if (isIdentifierStart(char)) { const identifier = this.parseIdentifier(); if (identifier === "true") { return true; } if (identifier === "false") { return false; } if (identifier === "null") { return null; } throw this.error(`Identifier ${identifier} is not a literal meta value.`); } throw this.error("Expected a literal workflow meta value."); } private parseObject(): Record { this.expect("{"); const object: Record = {}; this.skipTrivia(); while (this.peek() !== "}") { if (this.peek() === "[") { throw this.error( "Computed properties are not allowed in workflow meta." ); } if (this.input.startsWith("...", this.index)) { throw this.error("Spread properties are not allowed in workflow meta."); } const key = this.parseObjectKey(); this.skipTrivia(); this.expect(":"); object[key] = this.parseValue(); this.skipTrivia(); if (this.peek() !== ",") { break; } this.index += 1; this.skipTrivia(); if (this.peek() === "}") { break; } } this.expect("}"); return object; } private parseArray(): WorkflowJsonValue[] { this.expect("["); const array: WorkflowJsonValue[] = []; this.skipTrivia(); while (this.peek() !== "]") { if (this.input.startsWith("...", this.index)) { throw this.error("Spread elements are not allowed in workflow meta."); } array.push(this.parseValue()); this.skipTrivia(); if (this.peek() !== ",") { break; } this.index += 1; this.skipTrivia(); if (this.peek() === "]") { break; } } this.expect("]"); return array; } private parseObjectKey(): string { this.skipTrivia(); const char = this.peek(); if (char === '"' || char === "'") { return this.parseString(); } if (isIdentifierStart(char)) { return this.parseIdentifier(); } throw this.error("Expected a literal workflow meta property name."); } private parseString(): string { const quote = this.peek(); this.index += 1; let value = ""; while (!this.isDone()) { const char = this.input[this.index]; this.index += 1; if (char === quote) { return value; } if (char === "\\") { value += this.parseEscapeSequence(); } else { value += char; } } throw this.error("Unterminated string in workflow meta."); } private parseEscapeSequence(): string { const char = this.input[this.index]; this.index += 1; switch (char) { case '"': case "'": case "\\": case "/": return char; case "b": return "\b"; case "f": return "\f"; case "n": return "\n"; case "r": return "\r"; case "t": return "\t"; case "u": { const hex = this.input.slice(this.index, this.index + 4); if (!UNICODE_ESCAPE_RE.test(hex)) { throw this.error("Invalid unicode escape in workflow meta."); } this.index += 4; return String.fromCharCode(Number.parseInt(hex, 16)); } default: throw this.error("Unsupported escape sequence in workflow meta."); } } private parseNumber(): number { const match = NUMBER_RE.exec(this.input.slice(this.index)); if (!match) { throw this.error("Invalid number in workflow meta."); } this.index += match[0].length; const value = Number(match[0]); if (!Number.isFinite(value)) { throw this.error("Workflow meta numbers must be finite."); } return value; } private parseIdentifier(): string { let identifier = this.peek(); this.index += 1; while (isIdentifierPart(this.peek())) { identifier += this.peek(); this.index += 1; } return identifier; } private skipTrivia(): void { while (!this.isDone()) { const char = this.peek(); if (WHITESPACE_RE.test(char)) { this.index += 1; continue; } if (this.input.startsWith("//", this.index)) { this.index = this.findLineEnd(this.index + 2); continue; } if (this.input.startsWith("/*", this.index)) { const end = this.input.indexOf("*/", this.index + 2); if (end === -1) { throw this.error("Unterminated comment in workflow meta."); } this.index = end + 2; continue; } break; } } private findLineEnd(from: number): number { const end = this.input.indexOf("\n", from); return end === -1 ? this.input.length : end + 1; } private expect(expected: string): void { this.skipTrivia(); if (this.peek() !== expected) { throw this.error(`Expected ${expected} in workflow meta.`); } this.index += 1; } private peek(): string { return this.input[this.index] ?? ""; } private isDone(): boolean { return this.index >= this.input.length; } private error(message: string): WorkflowParseError { return new WorkflowParseError(message, this.index); } } export { WorkflowParseError }; export function parseWorkflowScript(source: string): ParsedWorkflow { const leadingWhitespace = LEADING_WHITESPACE_RE.exec(source)?.[0].length ?? 0; const afterLeadingWhitespace = source.slice(leadingWhitespace); const prefixMatch = META_PREFIX_RE.exec(afterLeadingWhitespace); if (!prefixMatch) { throw new WorkflowParseError( "Workflow scripts must start with `export const meta = { ... }`.", leadingWhitespace ); } const metaStart = leadingWhitespace + prefixMatch[0].length; if (source[metaStart] !== "{") { throw new WorkflowParseError( "Workflow meta must be an object literal.", metaStart ); } const metaEnd = findLiteralEnd(source, metaStart); const afterMeta = source.slice(metaEnd + 1); const statementEndOffset = STATEMENT_END_RE.exec(afterMeta)?.[0].length ?? 0; const statementEnd = metaEnd + 1 + statementEndOffset; const body = source.slice(statementEnd); validateWorkflowBody(body); const metaLiteral = source.slice(metaStart, metaEnd + 1); const parsedMeta = new LiteralParser(metaLiteral).parse(); if (!isWorkflowMetaObject(parsedMeta)) { throw new WorkflowParseError( "Workflow meta must be an object literal.", metaStart ); } validateWorkflowMeta(parsedMeta, metaStart); return { source, body, meta: parsedMeta, metaRange: { start: leadingWhitespace, end: statementEnd, }, }; } export function validateWorkflowBody(body: string): void { const maskedBody = maskStringsAndComments(body); for (const forbidden of FORBIDDEN_RUNTIME_PATTERNS) { if (forbidden.pattern.test(maskedBody)) { throw new WorkflowParseError(forbidden.message); } } if (containsForbiddenComputedCall(body, "Date", "now")) { throw new WorkflowParseError("Workflow scripts cannot call Date.now()."); } if (containsForbiddenComputedCall(body, "Math", "random")) { throw new WorkflowParseError("Workflow scripts cannot call Math.random()."); } } function validateWorkflowMeta( meta: Record, index: number ): asserts meta is WorkflowMeta { if (typeof meta.name !== "string" || !meta.name.trim()) { throw new WorkflowParseError( "Workflow meta.name must be a non-empty string.", index ); } if (typeof meta.description !== "string" || !meta.description.trim()) { throw new WorkflowParseError( "Workflow meta.description must be a non-empty string.", index ); } } function containsForbiddenComputedCall( source: string, objectName: string, propertyName: string ): boolean { let index = 0; while (index < source.length) { const char = source[index]; if (char === '"' || char === "'") { index = skipString(source, index); continue; } if (char === "`") { index = skipTemplate(source, index); continue; } if (source.startsWith("//", index)) { index = skipLineComment(source, index + 2); continue; } if (source.startsWith("/*", index)) { index = skipBlockComment(source, index + 2); continue; } if (isIdentifierAt(source, index, objectName)) { const access = parseComputedPropertyAccess( source, index + objectName.length ); if ( access?.property === propertyName && isCallAfter(source, access.end) ) { return true; } } index += 1; } return false; } function parseComputedPropertyAccess( source: string, start: number ): { property: string; end: number } | undefined { let index = skipWhitespace(source, start); if (source.startsWith("?.", index)) { index = skipWhitespace(source, index + 2); } if (source[index] !== "[") { return undefined; } return parseDeterministicPropertyName(source, index); } function parseDeterministicPropertyName( source: string, start: number ): { property: string; end: number } | undefined { let index = skipWhitespace(source, start + 1); let property = ""; let needsPart = true; while (index < source.length) { index = skipWhitespace(source, index); if (source[index] === "]") { return needsPart ? undefined : { property, end: index + 1 }; } if (!needsPart) { if (source[index] !== "+") { return undefined; } index = skipWhitespace(source, index + 1); } const part = parseStringLikePropertyPart(source, index); if (!part) { return undefined; } property += part.value; index = part.end; needsPart = false; } return undefined; } function parseStringLikePropertyPart( source: string, start: number ): { value: string; end: number } | undefined { const quote = source[start]; if (quote === '"' || quote === "'") { return parseQuotedPropertyPart(source, start, quote); } if (quote === "`") { return parseTemplatePropertyPart(source, start); } return undefined; } function parseQuotedPropertyPart( source: string, start: number, quote: string ): { value: string; end: number } | undefined { let index = start + 1; let value = ""; while (index < source.length) { const char = source[index]; if (char === quote) { return { value, end: index + 1 }; } if (char === "\\") { const escaped = source[index + 1]; if (escaped === undefined) { return undefined; } value += escaped; index += 2; continue; } value += char; index += 1; } return undefined; } function parseTemplatePropertyPart( source: string, start: number ): { value: string; end: number } | undefined { let index = start + 1; let value = ""; while (index < source.length) { const char = source[index]; if (char === "`") { return { value, end: index + 1 }; } if (char === "\\") { const escaped = source[index + 1]; if (escaped === undefined) { return undefined; } value += escaped; index += 2; continue; } if (source.startsWith("${", index)) { return undefined; } value += char; index += 1; } return undefined; } function isCallAfter(source: string, start: number): boolean { let index = skipWhitespace(source, start); if (source.startsWith("?.", index)) { index = skipWhitespace(source, index + 2); } return source[index] === "("; } function isIdentifierAt( source: string, index: number, identifier: string ): boolean { if (!source.startsWith(identifier, index)) { return false; } const before = source[index - 1] ?? ""; const after = source[index + identifier.length] ?? ""; return !(isIdentifierPart(before) || isIdentifierPart(after)); } function skipWhitespace(source: string, start: number): number { let index = start; while (WHITESPACE_RE.test(source[index] ?? "")) { index += 1; } return index; } function findLiteralEnd(source: string, start: number): number { let depth = 0; let index = start; while (index < source.length) { const char = source[index]; if (char === '"' || char === "'") { index = skipString(source, index); continue; } if (char === "`") { throw new WorkflowParseError( "Template literals are not allowed in workflow meta.", index ); } if (source.startsWith("//", index)) { index = skipLineComment(source, index + 2); continue; } if (source.startsWith("/*", index)) { index = skipBlockComment(source, index + 2); continue; } if (char === "{" || char === "[") { depth += 1; } else if (char === "}" || char === "]") { depth -= 1; if (depth === 0) { return index; } if (depth < 0) { throw new WorkflowParseError( "Unbalanced workflow meta literal.", index ); } } index += 1; } throw new WorkflowParseError("Unterminated workflow meta literal.", start); } function maskStringsAndComments(source: string): string { let masked = ""; let index = 0; while (index < source.length) { const char = source[index]; if (char === '"' || char === "'" || char === "`") { const end = char === "`" ? skipTemplate(source, index) : skipString(source, index); masked += " ".repeat(end - index); index = end; continue; } if (source.startsWith("//", index)) { const end = skipLineComment(source, index + 2); masked += " ".repeat(end - index); index = end; continue; } if (source.startsWith("/*", index)) { const end = skipBlockComment(source, index + 2); masked += " ".repeat(end - index); index = end; continue; } masked += char; index += 1; } return masked; } function skipString(source: string, start: number): number { const quote = source[start]; let index = start + 1; while (index < source.length) { const char = source[index]; index += char === "\\" ? 2 : 1; if (char === quote) { return index; } } throw new WorkflowParseError("Unterminated string literal.", start); } function skipTemplate(source: string, start: number): number { let index = start + 1; while (index < source.length) { const char = source[index]; index += char === "\\" ? 2 : 1; if (char === "`") { return index; } } throw new WorkflowParseError("Unterminated template literal.", start); } function skipLineComment(source: string, start: number): number { const end = source.indexOf("\n", start); return end === -1 ? source.length : end + 1; } function skipBlockComment(source: string, start: number): number { const end = source.indexOf("*/", start); if (end === -1) { throw new WorkflowParseError("Unterminated block comment.", start - 2); } return end + 2; } function isWorkflowMetaObject( value: WorkflowJsonValue ): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function isDigit(value: string): boolean { return value >= "0" && value <= "9"; } function isIdentifierStart(value: string): boolean { return IDENTIFIER_START_RE.test(value); } function isIdentifierPart(value: string): boolean { return IDENTIFIER_PART_RE.test(value); }