@@ -1905,10 +1905,15 @@ describe('AutomationEngine - Safe Expression Evaluation', () => {
19051905
19061906 it ( 'should not execute malicious code' , ( ) => {
19071907 const vars = new Map < string , unknown > ( ) ;
1908- // These should all return false safely
1909- expect ( engine . evaluateCondition ( 'process.exit(1)' , vars ) ) . toBe ( false ) ;
1910- expect ( engine . evaluateCondition ( 'require("fs").readFileSync("/etc/passwd")' , vars ) ) . toBe ( false ) ;
1911- expect ( engine . evaluateCondition ( '(() => { while(true) {} })()' , vars ) ) . toBe ( false ) ;
1908+ // None of these is a host-language program to this engine — there is no
1909+ // `new Function`, no `eval`, no `require` on either path. They REFUSE
1910+ // rather than return `false` since #4336: a brace-free condition is CEL,
1911+ // and CEL has no `process`, no `require`, and no arrow-function syntax,
1912+ // so each one is a fault the run reports. The safety property is
1913+ // unchanged (nothing executes) and the diagnosis is no longer silent.
1914+ expect ( ( ) => engine . evaluateCondition ( 'process.exit(1)' , vars ) ) . toThrow ( / e x i t / ) ;
1915+ expect ( ( ) => engine . evaluateCondition ( 'require("fs").readFileSync("/etc/passwd")' , vars ) ) . toThrow ( / r e q u i r e / ) ;
1916+ expect ( ( ) => engine . evaluateCondition ( '(() => { while(true) {} })()' , vars ) ) . toThrow ( / s o u r c e : / ) ;
19121917 } ) ;
19131918
19141919 it ( 'should handle string comparisons' , ( ) => {
@@ -1917,6 +1922,106 @@ describe('AutomationEngine - Safe Expression Evaluation', () => {
19171922
19181923 expect ( engine . evaluateCondition ( '{status} == active' , vars ) ) . toBe ( true ) ;
19191924 expect ( engine . evaluateCondition ( '{status} != inactive' , vars ) ) . toBe ( true ) ;
1925+ // #4336 — a QUOTED literal on the right compares as its contents. This is
1926+ // the spelling the flow docs show for a decision node, and it used to
1927+ // compare `active` against `'active'` (quotes included) and be false for
1928+ // every value of `status`.
1929+ expect ( engine . evaluateCondition ( "{status} == 'active'" , vars ) ) . toBe ( true ) ;
1930+ expect ( engine . evaluateCondition ( '{status} == "active"' , vars ) ) . toBe ( true ) ;
1931+ expect ( engine . evaluateCondition ( "{status} == 'closed'" , vars ) ) . toBe ( false ) ;
1932+ expect ( engine . evaluateCondition ( "{status} != 'closed'" , vars ) ) . toBe ( true ) ;
1933+ } ) ;
1934+ } ) ;
1935+
1936+ // ─── #4336: a bare-string condition is CEL, not a string compare ─────
1937+ //
1938+ // The reported defect: `evaluateCondition` branched on whether an `Expression`
1939+ // envelope was present, so a condition authored as a plain string never reached
1940+ // the CEL engine and both sides were compared as TEXT. Every case below is one
1941+ // of the two failure directions from the issue (a gate that never opens, a
1942+ // branch pinned open) or one of the two silent `false`s found afterwards.
1943+ describe ( 'AutomationEngine - bare-string conditions evaluate as CEL (#4336)' , ( ) => {
1944+ let engine : AutomationEngine ;
1945+ beforeEach ( ( ) => { engine = new AutomationEngine ( createTestLogger ( ) ) ; } ) ;
1946+
1947+ it ( 'opens a null-check gate that used to be pinned shut' , ( ) => {
1948+ // `'existingTask' === 'null'` → false, forever. The flow selected its
1949+ // records, took no branch, and recorded `success`.
1950+ expect ( engine . evaluateCondition ( 'existingTask == null' , new Map ( [ [ 'existingTask' , null ] ] ) ) ) . toBe ( true ) ;
1951+ expect ( engine . evaluateCondition ( 'existingTask == null' , new Map ( [ [ 'existingTask' , { id : 'a' } ] ] ) ) ) . toBe ( false ) ;
1952+ } ) ;
1953+
1954+ it ( 'gates a numeric comparison that used to be pinned open' , ( ) => {
1955+ // `'record.rating' >= '4'` → `'r' > '4'` → true for every record.
1956+ const low = new Map < string , unknown > ( [ [ 'record' , { rating : 2 } ] ] ) ;
1957+ const high = new Map < string , unknown > ( [ [ 'record' , { rating : 5 } ] ] ) ;
1958+ expect ( engine . evaluateCondition ( 'record.rating >= 4' , high ) ) . toBe ( true ) ;
1959+ expect ( engine . evaluateCondition ( 'record.rating >= 4' , low ) ) . toBe ( false ) ;
1960+ } ) ;
1961+
1962+ it ( 'evaluates a bare truthy gate instead of answering false' , ( ) => {
1963+ // No comparison operator at all: the template path fell through to
1964+ // `Number('record.isActive')` → NaN → `false`.
1965+ expect ( engine . evaluateCondition ( 'record.isActive' , new Map < string , unknown > ( [ [ 'record' , { isActive : true } ] ] ) ) ) . toBe ( true ) ;
1966+ expect ( engine . evaluateCondition ( 'record.isActive' , new Map < string , unknown > ( [ [ 'record' , { isActive : false } ] ] ) ) ) . toBe ( false ) ;
1967+ } ) ;
1968+
1969+ it ( 'resolves field access on an object variable — the get_record output shape' , ( ) => {
1970+ // `get_record`'s `outputVariable` stores the WHOLE record under one name,
1971+ // which is why the `{lead_record.status}` spelling can never resolve.
1972+ const vars = new Map < string , unknown > ( [ [ 'lead_record' , { status : 'converted' } ] ] ) ;
1973+ expect ( engine . evaluateCondition ( "lead_record.status == 'converted'" , vars ) ) . toBe ( true ) ;
1974+ expect ( engine . evaluateCondition ( "lead_record.status == 'new'" , vars ) ) . toBe ( false ) ;
1975+ } ) ;
1976+
1977+ it ( 'refuses a brace-wrapped reference that resolves to nothing, naming it' , ( ) => {
1978+ const vars = new Map < string , unknown > ( [ [ 'lead_record' , { status : 'converted' } ] ] ) ;
1979+ // Silently false today even though the status IS 'converted'.
1980+ expect ( ( ) => engine . evaluateCondition ( "{lead_record.status} == 'converted'" , vars ) )
1981+ . toThrow ( / ` \{ l e a d _ r e c o r d \. s t a t u s \} ` d i d n o t r e s o l v e / ) ;
1982+ expect ( ( ) => engine . evaluateCondition ( "{lead_record.status} == 'converted'" , vars ) )
1983+ . toThrow ( / D r o p t h e b r a c e s / ) ;
1984+ // Same for a brace-wrapped truthy gate.
1985+ expect ( ( ) => engine . evaluateCondition ( '{record.isActive}' , new Map < string , unknown > ( [ [ 'record' , { isActive : true } ] ] ) ) )
1986+ . toThrow ( / d i d n o t r e s o l v e / ) ;
1987+ } ) ;
1988+
1989+ it ( 'refuses a template-dialect condition it cannot turn into a predicate' , ( ) => {
1990+ // `{status}` substitutes to `open` — not a boolean, not a number, and no
1991+ // operator to compare it with. That used to be `false`.
1992+ expect ( ( ) => engine . evaluateCondition ( '{status}' , new Map ( [ [ 'status' , 'open' ] ] ) ) )
1993+ . toThrow ( / i s n o t a p r e d i c a t e / ) ;
1994+ // A boolean or numeric value still reads as a gate.
1995+ expect ( engine . evaluateCondition ( '{flag}' , new Map ( [ [ 'flag' , true ] ] ) ) ) . toBe ( true ) ;
1996+ expect ( engine . evaluateCondition ( '{flag}' , new Map ( [ [ 'flag' , false ] ] ) ) ) . toBe ( false ) ;
1997+ expect ( engine . evaluateCondition ( '{count}' , new Map ( [ [ 'count' , 3 ] ] ) ) ) . toBe ( true ) ;
1998+ expect ( engine . evaluateCondition ( '{count}' , new Map ( [ [ 'count' , 0 ] ] ) ) ) . toBe ( false ) ;
1999+ } ) ;
2000+
2001+ it ( 'does not mistake braces inside a string literal for a template hole' , ( ) => {
2002+ // `'{pending}'` is text the predicate compares AGAINST, not a reference to
2003+ // substitute. The dialect sniff reads the source outside string literals,
2004+ // so this stays CEL and compares the field.
2005+ expect ( engine . evaluateCondition ( "record.label == '{pending}'" ,
2006+ new Map < string , unknown > ( [ [ 'record' , { label : '{pending}' } ] ] ) ) ) . toBe ( true ) ;
2007+ expect ( engine . evaluateCondition ( "record.label == '{pending}'" ,
2008+ new Map < string , unknown > ( [ [ 'record' , { label : 'pending' } ] ] ) ) ) . toBe ( false ) ;
2009+ } ) ;
2010+
2011+ it ( 'keeps braces inside an explicit CEL envelope a hard error' , ( ) => {
2012+ // The dialect sniff applies only where no dialect was stated. `dialect:
2013+ // 'cel'` is the author saying "this is CEL", where `{…}` is a map literal
2014+ // and the #1491 brace-trap.
2015+ expect ( ( ) => engine . evaluateCondition ( { dialect : 'cel' , source : '{record.rating} >= 4' } ,
2016+ new Map < string , unknown > ( [ [ 'record' , { rating : 5 } ] ] ) ) ) . toThrow ( / s o u r c e : / ) ;
2017+ } ) ;
2018+
2019+ it ( 'treats an absent or empty condition as no branch, not as a fault' , ( ) => {
2020+ // A `decision` entry with no `expression` is the one caller that does not
2021+ // pre-check; an unauthored branch must not open, and must not throw either.
2022+ expect ( engine . evaluateCondition ( '' , new Map ( ) ) ) . toBe ( false ) ;
2023+ expect ( engine . evaluateCondition ( ' ' , new Map ( ) ) ) . toBe ( false ) ;
2024+ expect ( engine . evaluateCondition ( undefined as unknown as string , new Map ( ) ) ) . toBe ( false ) ;
19202025 } ) ;
19212026} ) ;
19222027
0 commit comments