feat(compiler): SYN004 — warn on eval() and new Function() calls in fn bodies (?bs 0.7+)#145
Merged
Merged
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces SYN004, a new ?bs 0.7+ compiler warning intended to flag dynamic code execution via eval(...) and new Function(...) in fn bodies, with suppression inside unsafe "reason" { ... } blocks and unsafe "reason" fn bodies. It also wires the new diagnostic through the compiler’s error-code registry, MCP explain support, docs, and tests.
Changes:
- Add SYN004 to the compiler registry and implement token-based detection in
passSynCheck. - Add a new SYN004 test suite and extend existing “known codes” allowlists.
- Add SYN004 to MCP explanations and end-user documentation lists (README / AGENTS).
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Adds SYN004 to the list of codes supported by the explain tool. |
| packages/mcp/tests/server.test.ts | Adds SYN004 to MCP KNOWN_CODES contract test. |
| packages/mcp/src/explanations.ts | Adds long-form SYN004 explanation and example pair. |
| packages/compiler/tests/syn004-check.test.ts | Adds SYN004 detection/suppression/severity tests. |
| packages/compiler/tests/error-codes.test.ts | Adds SYN004 to the exhaustive error-code allowlist. |
| packages/compiler/src/passes/syn-check.ts | Implements SYN004 detection for eval / new Function in fn bodies. |
| packages/compiler/src/error-codes.ts | Registers SYN004 metadata (rule/idiom/rewrite/example). |
| AGENTS.md | Documents SYN004 in the diagnostic table. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+384
to
+387
| message: | ||
| `fn '${decl.name}' constructs ${hasNew ? "new " : ""}Function${funcCallSep}() — ` + | ||
| `the Function constructor executes a string as code and bypasses all static checks; ` + | ||
| `refactor to explicit code or wrap in unsafe "reason" { new Function(body) }`, |
Comment on lines
+287
to
+298
| // Must be followed by `(` (direct call) or `?.(` (optional call). | ||
| const nextIdx4 = nextSignificant(tokens, i + 1); | ||
| const next4 = tokens[nextIdx4]; | ||
| let isOptEval = false; | ||
| let callIdx4 = nextIdx4; | ||
| if (next4 && next4.kind === "questionDot") { | ||
| isOptEval = true; | ||
| callIdx4 = nextSignificant(tokens, nextIdx4 + 1); | ||
| } | ||
| const callTok4 = tokens[callIdx4]; | ||
| if (!callTok4 || !(callTok4.kind === "open" && callTok4.text === "(")) continue; | ||
|
|
Comment on lines
+346
to
+357
| // Exclude: `Function.prototype.*` — followed by `.` (member access, not a call) | ||
| // Must be followed by `(` (direct call) or `?.(` (optional call). | ||
| const nextIdx4 = nextSignificant(tokens, i + 1); | ||
| const next4 = tokens[nextIdx4]; | ||
| let isOptFunc = false; | ||
| let callIdx4 = nextIdx4; | ||
| if (next4 && next4.kind === "questionDot") { | ||
| isOptFunc = true; | ||
| callIdx4 = nextSignificant(tokens, nextIdx4 + 1); | ||
| } | ||
| const callTok4 = tokens[callIdx4]; | ||
| if (!callTok4 || !(callTok4.kind === "open" && callTok4.text === "(")) continue; |
| const result = transform(src); | ||
| expect(result.warnings.some((w) => w.code === "SYN004")).toBe(true); | ||
| }); | ||
|
|
Comment on lines
+318
to
+326
| if (callTok4.matchedAt !== undefined) { | ||
| const afterCloseIdx = nextSignificant(tokens, callTok4.matchedAt + 1); | ||
| const afterClose = tokens[afterCloseIdx]; | ||
| if (afterClose && ( | ||
| (afterClose.kind === "open" && afterClose.text === "{") || | ||
| (afterClose.kind === "punct" && afterClose.text === ":") || | ||
| afterClose.kind === "fatArrow" | ||
| )) continue; | ||
| } |
Comment on lines
+392
to
+400
| if (callTok4.matchedAt !== undefined) { | ||
| const afterCloseIdx = nextSignificant(tokens, callTok4.matchedAt + 1); | ||
| const afterClose = tokens[afterCloseIdx]; | ||
| if (afterClose && ( | ||
| (afterClose.kind === "open" && afterClose.text === "{") || | ||
| (afterClose.kind === "punct" && afterClose.text === ":") || | ||
| afterClose.kind === "fatArrow" | ||
| )) continue; | ||
| } |
Comment on lines
+18
to
+19
|
|
||
| it("fires on eval() with a string literal", () => { |
ffe28b1 to
98cadc3
Compare
Comment on lines
+324
to
+336
| // Exclude declarations: `function eval(params) {}` and method shorthands `{ eval(params) {} }`. | ||
| // Only exclude when `)` is directly followed by `{` (method body) or `=>` (arrow method). | ||
| // We intentionally omit `:` here: `eval(x) : y` in a ternary would be incorrectly | ||
| // excluded, causing a false negative. Real `eval(): T {}` declarations are caught by | ||
| // the `{` check via the type annotation scan, and the pattern is rare in botscript. | ||
| if (callTok4.matchedAt !== undefined) { | ||
| const afterCloseIdx = nextSignificant(tokens, callTok4.matchedAt + 1); | ||
| const afterClose = tokens[afterCloseIdx]; | ||
| if (afterClose && ( | ||
| (afterClose.kind === "open" && afterClose.text === "{") || | ||
| afterClose.kind === "fatArrow" | ||
| )) continue; | ||
| } |
Comment on lines
+401
to
+411
| // Exclude declarations: `function Function(params) {}` and method shorthands. | ||
| // Omit `:` for the same reason as the eval check above — it causes false negatives | ||
| // in ternary branches (e.g. `cond ? Function(body) : fallback`). | ||
| if (callTok4.matchedAt !== undefined) { | ||
| const afterCloseIdx = nextSignificant(tokens, callTok4.matchedAt + 1); | ||
| const afterClose = tokens[afterCloseIdx]; | ||
| if (afterClose && ( | ||
| (afterClose.kind === "open" && afterClose.text === "{") || | ||
| afterClose.kind === "fatArrow" | ||
| )) continue; | ||
| } |
| | SYN003 | (0.7+, warning) A fn body contains a `console.*` call (console.log, console.error, etc.). Direct console output bypasses the `stdout`/`stderr` capability model — the compiler cannot enforce or surface the output declaration for callers. | Replace `console.log(...)` with `stdout.write(...)` and add `uses { stdout }` to the fn header; replace `console.error(...)` with `stderr.write(...)` and add `uses { stderr }`. | | ||
| | SYN005 | (0.7+, warning) A fn body accesses `process.env`. `process.env` is a global deployment-environment namespace — access is invisible to callers, there is no capability or resource declaration that covers it, and the fn has an undeclared dependency on deployment configuration. Detection: `process` not preceded by `.`/`?.`, followed by `.`/`?.` then `env`. `obj.process.env` (member access on a local), `unsafe {}` blocks, and `unsafe "reason" fn` bodies are excluded. | Pass config and secrets as explicit fn parameters so the dependency is visible in the call signature; if env access is required at the load site, wrap in `unsafe "reads deployment env" { }`. | | ||
| | SYN004 | (0.7+, warning) A fn body calls `eval(...)` / `eval?.(...)` (global eval not preceded by `.`/`?.`) or calls `Function(...)` / `Function?.(...)` / `new Function(...)` (Function constructor not preceded by `.`/`?.`). All forms execute strings as code at runtime — every static capability check (CAP001/CAP002), resource declaration (reads/writes), and safety check (SYN002/SYN003) can be bypassed by routing any unsafe pattern through eval or the Function constructor. Suppressed inside `unsafe {}` blocks and `unsafe fn` bodies. `.eval(...)` (method call on a local) and `Function.*` member accesses are excluded. | Refactor the eval-based pattern to use explicit code paths. If eval is genuinely required (e.g. sandboxed interpreter), wrap in `unsafe "<reason>" { eval(...) }`. | | ||
| | SYN005 | (0.7+, warning) A fn body accesses `process.env`. `process.env` is a global deployment-environment namespace — access is invisible to callers, there is no capability or resource declaration that depends on deployment configuration. Detection: `process` not preceded by `.`/`?.`, followed by `.`/`?.` then `env`. `obj.process.env` (member access on a local), `unsafe {}` blocks, and `unsafe "reason" fn` bodies are excluded. | Pass config and secrets as explicit fn parameters so the dependency is visible in the call signature; if env access is required at the load site, wrap in `unsafe "reads deployment env" { }`. | |
| | SYN003 | (0.7+, warning) A fn body contains a `console.*` call (console.log, console.error, etc.). Direct console output bypasses the `stdout`/`stderr` capability model — the compiler cannot enforce or surface the output declaration for callers. | Replace `console.log(...)` with `stdout.write(...)` and add `uses { stdout }` to the fn header; replace `console.error(...)` with `stderr.write(...)` and add `uses { stderr }`. | | ||
| | SYN005 | (0.7+, warning) A fn body accesses `process.env`. `process.env` is a global deployment-environment namespace — access is invisible to callers, there is no capability or resource declaration that covers it, and the fn has an undeclared dependency on deployment configuration. Detection: `process` not preceded by `.`/`?.`, followed by `.`/`?.` then `env`. `obj.process.env` (member access on a local), `unsafe {}` blocks, and `unsafe "reason" fn` bodies are excluded. | Pass config and secrets as explicit fn parameters so the dependency is visible in the call signature; if env access is required at the load site, wrap in `unsafe "reads deployment env" { }`. | | ||
| | SYN004 | (0.7+, warning) A fn body calls `eval(...)` / `eval?.(...)` (global eval not preceded by `.`/`?.`) or calls `Function(...)` / `Function?.(...)` / `new Function(...)` (Function constructor not preceded by `.`/`?.`). All forms execute strings as code at runtime — every static capability check (CAP001/CAP002), resource declaration (reads/writes), and safety check (SYN002/SYN003) can be bypassed by routing any unsafe pattern through eval or the Function constructor. Suppressed inside `unsafe {}` blocks and `unsafe fn` bodies. `.eval(...)` (method call on a local) and `Function.*` member accesses are excluded. | Refactor the eval-based pattern to use explicit code paths. If eval is genuinely required (e.g. sandboxed interpreter), wrap in `unsafe "<reason>" { eval(...) }`. | | ||
| | SYN005 | (0.7+, warning) A fn body accesses `process.env`. `process.env` is a global deployment-environment namespace — access is invisible to callers, there is no capability or resource declaration that depends on deployment configuration. Detection: `process` not preceded by `.`/`?.`, followed by `.`/`?.` then `env`. `obj.process.env` (member access on a local), `unsafe {}` blocks, and `unsafe "reason" fn` bodies are excluded. | Pass config and secrets as explicit fn parameters so the dependency is visible in the call signature; if env access is required at the load site, wrap in `unsafe "reads deployment env" { }`. | |
Comment on lines
+555
to
+558
| idiom: | ||
| "refactor eval-based patterns to use explicit code paths or config parameters; " + | ||
| "if eval is unavoidable (e.g. a sandboxed interpreter or intentional scripting surface), " + | ||
| "wrap in `unsafe \"<reason>\" { }` to make the escape hatch visible in the diff", |
Comment on lines
+761
to
+764
| "SYN004 fires at `?bs 0.7+` as a non-blocking warning. Detection is token-based: " + | ||
| "`eval` not preceded by `.`/`?.` followed by `(` or `?.(`; bare `Function(...)` / " + | ||
| "`Function?.(...)` / `new Function(...)` not preceded by `.`/`?.`. " + | ||
| "`.eval(...)` (method call on a local object) and `Function.*` member accesses are excluded.", |
| // TypeScript instantiation form: eval<T>(...) | ||
| let depth = 1; | ||
| let j = nextIdx4 + 1; | ||
| while (j < tokens.length && depth > 0) { |
| // TypeScript instantiation form: Function<T>(...) / new Function<T>(...) | ||
| let depth = 1; | ||
| let j = nextIdx4 + 1; | ||
| while (j < tokens.length && depth > 0) { |
463bb7b to
1bd1069
Compare
Copilot stopped reviewing on behalf of
marcelofarias due to an error
June 11, 2026 14:41
Comment on lines
+305
to
+321
| } else if (next4 && next4.kind === "operator" && next4.text === "<") { | ||
| // TypeScript instantiation form: eval<T>(...) | ||
| let depth = 1; | ||
| let j = nextIdx4 + 1; | ||
| while (j < tokens.length && depth > 0) { | ||
| const t = tokens[j]; | ||
| if (!t) break; | ||
| if (t.kind === "operator" && t.text === "<") depth++; | ||
| else if (t.kind === "operator" && (t.text === ">" || t.text === ">>" || t.text === ">>>")) | ||
| depth -= t.text.length; | ||
| j++; | ||
| } | ||
| const afterGenericIdx4 = nextSignificant(tokens, j); | ||
| const afterGeneric4 = tokens[afterGenericIdx4]; | ||
| if (afterGeneric4 && afterGeneric4.kind === "open" && afterGeneric4.text === "(") | ||
| callIdx4 = afterGenericIdx4; | ||
| } |
Comment on lines
+305
to
+321
| } else if (next4 && next4.kind === "operator" && next4.text === "<") { | ||
| // TypeScript instantiation form: eval<T>(...) | ||
| let depth = 1; | ||
| let j = nextIdx4 + 1; | ||
| while (j < tokens.length && depth > 0) { | ||
| const t = tokens[j]; | ||
| if (!t) break; | ||
| if (t.kind === "operator" && t.text === "<") depth++; | ||
| else if (t.kind === "operator" && (t.text === ">" || t.text === ">>" || t.text === ">>>")) | ||
| depth -= t.text.length; | ||
| j++; | ||
| } | ||
| const afterGenericIdx4 = nextSignificant(tokens, j); | ||
| const afterGeneric4 = tokens[afterGenericIdx4]; | ||
| if (afterGeneric4 && afterGeneric4.kind === "open" && afterGeneric4.text === "(") | ||
| callIdx4 = afterGenericIdx4; | ||
| } |
Comment on lines
+383
to
+399
| } else if (next4 && next4.kind === "operator" && next4.text === "<") { | ||
| // TypeScript instantiation form: Function<T>(...) / new Function<T>(...) | ||
| let depth = 1; | ||
| let j = nextIdx4 + 1; | ||
| while (j < tokens.length && depth > 0) { | ||
| const t = tokens[j]; | ||
| if (!t) break; | ||
| if (t.kind === "operator" && t.text === "<") depth++; | ||
| else if (t.kind === "operator" && (t.text === ">" || t.text === ">>" || t.text === ">>>")) | ||
| depth -= t.text.length; | ||
| j++; | ||
| } | ||
| const afterGenericIdx4 = nextSignificant(tokens, j); | ||
| const afterGeneric4 = tokens[afterGenericIdx4]; | ||
| if (afterGeneric4 && afterGeneric4.kind === "open" && afterGeneric4.text === "(") | ||
| callIdx4 = afterGenericIdx4; | ||
| } |
Comment on lines
+352
to
+355
| message: | ||
| `fn '${decl.name}' calls eval${callSep4}() — ` + | ||
| `eval executes a string as code and bypasses all static capability, ` + | ||
| `resource, and safety checks; refactor to explicit code or wrap in unsafe "reason" { eval(src) }`, |
Comment on lines
+436
to
+439
| message: | ||
| `fn '${decl.name}' constructs ${hasNew ? "new " : ""}Function${funcCallSep}() — ` + | ||
| `the Function constructor executes a string as code and bypasses all static checks; ` + | ||
| `refactor to explicit code or wrap in unsafe "reason" { ${hasNew ? "new Function(body)" : "Function(body)"} }`, |
|
|
||
| for (const { decl } of program.fns) { | ||
| // An `unsafe "reason" fn` body is an explicit acknowledgment — skip SYN002/SYN003/SYN005. | ||
| // An `unsafe "reason" fn` body is an explicit acknowledgment — skip SYN002/SYN003/SYN004/SYN005. |
|
|
||
| for (const { decl } of program.fns) { | ||
| // An `unsafe "reason" fn` body is an explicit acknowledgment — skip SYN002/SYN003/SYN005. | ||
| // An `unsafe "reason" fn` body is an explicit acknowledgment — skip SYN002/SYN003/SYN004/SYN005. |
…n bodies (?bs 0.7+)
eval() and new Function() execute strings as code at runtime, bypassing
every static check botscript provides: CAP001/CAP002 cannot see capability
calls hidden in the string, reads/writes labels are unenforceable, and
SYN002/SYN003 checks can be routed around entirely. This is the universal
bypass — a fn could use eval('process.env.KEY') with no SYN005, or
eval('http.get(...)') with no CAP001.
Detection (token-based, same suppression model as SYN002/SYN003):
- eval(...) — 'eval' ident not preceded by ./ ?., followed by (
- new Function(...) — 'new' followed by 'Function' followed by (
- .eval() (method call on a local) and Function.* member accesses: excluded
- Suppressed inside unsafe {} blocks and unsafe fn bodies
Closes #144 (filed as SYN006; implemented as SYN004 — next available code
on main, SYN004/SYN005 are still in open PRs).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
`Function(body)` without `new` is equivalent to `new Function(body)` at runtime and was an easy bypass of SYN004's intent. Now both forms fire. Also fixes Copilot's comment about the explanations.ts cross-reference to SYN005 — reworded to not depend on SYN005 existing on main yet. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… bare Function() detection - Add `Function?.(` optional call detection in syn-check.ts (mirrors eval?.( handling) - Update error-codes.ts, MCP explanations, AGENTS.md, and test header/describe label to mention bare `Function(...)` / `Function?.(...)` alongside `new Function(...)` — the previous commit added bare Function() detection but left docs still saying only new Function() - Fix "behaviour" → "behavior" in MCP explanation (American English consistency) - Add test for Function?.() optional call form Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n; fix message text
- eval and Function detection now skip when the paren group is followed by
`{`, `:`, or `=>` — catches `function eval(x) {}` and method shorthands
that would otherwise false-positive
- Warning messages use concrete unsafe block examples (`{ eval(src) }`,
`{ new Function(body) }`) instead of the confusing empty `{ }`
- error-codes.ts title: "call bypasses" → "calls bypass" (plural)
- explanations.ts: clarify Function constructor bullet — not SYN003/Result
contract specifically, but all static checks
- Two new not-fires tests covering the declaration exclusion
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…syntax When the flagged call is bare Function(...) (no `new`), the suppression example in the warning message now matches the detected form. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tion<T>() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ry regression tests
The ): exclusion was causing false negatives: cond ? eval(x) : y was
not firing SYN004 because the ternary colon after eval(x) matched the
`:` (return-type annotation) exclusion. The `{` and `=>` exclusions
correctly handle all real declaration contexts (method shorthands and
function declarations). Removes the `:` case from both eval and Function
detection and adds two regression tests for ternary branches.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n ternary then-branch
- Add `:` to declaration-exclusion check in both eval and Function blocks,
so `function eval(x): T {}` and `{ eval(x): T {} }` are not flagged.
- Guard the `:` check with `isTernaryConsequent` in both blocks to avoid
suppressing `? eval(x) : y` and `? Function(x) : y` (both are real calls).
- Fix grammar in explanations.ts: "bypasses" → "bypass" (plural subject).
- Add 4 regression tests: return-type-annotated eval/Function declarations
must not fire; Function() in ternary then-branch must fire.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add missing eval?.(…) optional-call test case (SYN004 fires on this form but it was untested — Function?.() had a test, eval?.() did not) - Clarify SYN005 AGENTS.md row: rephrase from the ambiguous "no declaration depends on deployment config" to the accurate "no capability covers it; fn has an undeclared dependency on deployment configuration" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ers instantiation forms Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ken before new When `? new Function(body) : other`, prev4 is `new` (not `?`), so the old isTernaryConsequent4 check evaluated to false and the `:` guard skipped the warning as if it were a return-type annotation. Now also check the token before `new` for `?`. Add regression test.
1bd1069 to
e6c0872
Compare
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Summary
eval(...), constructsnew Function(...), or calls bareFunction(...)/ optional-call forms at?bs 0.7+eval()andFunction()/new Function()execute strings as code at runtime — every static check botscript provides can be bypassed by routing any unsafe pattern through eval (CAP001/CAP002, reads/writes declarations, SYN002/SYN003)unsafe "reason" {}blocks andunsafe "reason" fnbodiesunsafe "<reason>" { eval(...) }Closes #144 (filed as SYN006; implemented as SYN004 — next available code on main; SYN005 is still in open PR #143).
Changes
packages/compiler/src/error-codes.tspackages/compiler/src/passes/syn-check.tsevalnot preceded by./?.then(or?.(or<T>(;new Function/ bareFunction+ optional/generic call forms; unsafe suppression; ternary guard; return-type-annotated declaration exclusionpackages/compiler/tests/syn004-check.test.tseval?.(),Function?.())packages/compiler/tests/error-codes.test.tspackages/mcp/src/explanations.tspackages/mcp/tests/server.test.tsAGENTS.mdREADME.mdexplaintool code listTest plan
pnpm -r buildpassesnpx vitest run— 26/26 SYN004 tests passeval(code),eval('string'),eval<any>(code),eval?.(code)new Function(body),new Function<any>(body), bareFunction(body),Function?.(body)?bs 0.7, insideunsafe {}, insideunsafe fnvm.eval()(member call on local),Function.prototype.call(), bareFunctionreferencefunction eval(x: T): U {})eval/Functionin ternary branches (not suppressed by:)explaintool returns long-form for SYN004; example pair compiles correctly🤖 Generated with Claude Code