From fada762c57bf152c0223c8c5954780664f9a2594 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Mon, 8 Jun 2026 19:42:16 -0300 Subject: [PATCH 01/11] =?UTF-8?q?feat(compiler):=20SYN004=20=E2=80=94=20wa?= =?UTF-8?q?rn=20on=20eval()=20and=20new=20Function()=20calls=20in=20fn=20b?= =?UTF-8?q?odies=20(=3Fbs=200.7+)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- AGENTS.md | 1 + README.md | 2 +- packages/compiler/src/error-codes.ts | 31 +++++ packages/compiler/src/passes/syn-check.ts | 102 ++++++++++++++- packages/compiler/tests/error-codes.test.ts | 2 +- packages/compiler/tests/syn004-check.test.ts | 131 +++++++++++++++++++ packages/mcp/src/explanations.ts | 35 +++++ packages/mcp/tests/server.test.ts | 1 + 8 files changed, 301 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/tests/syn004-check.test.ts diff --git a/AGENTS.md b/AGENTS.md index 4663a03a..bec8e4ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -198,6 +198,7 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope. | SYN001 | Duplicate fn header clause (e.g. two `reads { }` on the same fn, or two `intent:`, or two `throws {}`), or a label inside `reads {}` / `writes {}` / `throws {}` that is not a plain identifier. `parseFn` is version-agnostic, so SYN001 fires whenever a duplicate clause is written regardless of the `?bs` pin. | Declare each header clause once; merge label lists rather than repeating the clause; use bare identifiers (not quoted strings) as labels. | | SYN002 | (0.7+, warning) A fn body contains a native `throw` statement. Native throws bypass botscript's Result-based error contract: callers using `?` unwrap or `match` on Result will not observe exceptions raised via `throw`. | Replace `throw new ErrorType(...)` with `return err(new ErrorType(...))` and update the return type to `Result`. | | 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 }`. | +| SYN004 | (0.7+, warning) A fn body calls `eval(...)` (global eval not preceded by `.`/`?.`) or constructs `new Function(...)`. Both 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. 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 "" { 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 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" { }`. | | SYN006 | (0.7+, warning) A fn body calls `process.exit()`, `process?.exit()`, or `process.exit?.()`. All forms terminate the entire host process — not just the fn, not just the bot. They produce no return value, bypass `Result` propagation, `throws {}`, `match`, and any caller recovery path. No capability declaration covers them. Detection: `process` not preceded by `.`/`?.`, followed by `.`/`?.` then `exit` then `(` or `?.(`. `obj.process.exit(...)`, `process.exit` without `(`, and `process.exitCode` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Return `err(...)` and let the caller decide whether to terminate. If `process.exit` is genuinely required at a bootstrap entry point, wrap in `unsafe "exits on invalid config" { process.exit(1) }`. | | INT002 | (0.7+) A fn declares `intent: "pure"` but its body directly references a stdlib capability (e.g. `http.get`, `fs.read`). Pure intent is enforced at the body level as well as the header. | Remove the stdlib call from the body, or change the intent. | diff --git a/README.md b/README.md index b1cbc6ec..2ce0d9b1 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ claude mcp add botscript -- npx -y @mbfarias/botscript-mcp | ----------- | -------------------------------------- | --------------------------------------------------------------------------------------------------- | | `primer` | (no args) | The canonical language primer (same text the `?primer` directive emits). | | `transform` | `{ source: string, filename?: string }` | `{ ok: true, code, forms, version, warnings: [...] }` on success, or `{ ok: false, diagnostics: [...] }` on failure. `warnings` is an array of non-blocking diagnostics (e.g. CAP003). | -| `explain` | `{ code: string }` | Long-form explanation for any stable diagnostic code (`ALI001`, `ALI002`, `ALI003`, `BS001`, `BS002`, `CAP001`–`CAP003`, `DEP001`–`DEP004`, `EFF002`–`EFF004`, `FMT001`, `INT001`–`INT005`, `MAT001`–`MAT004`, `RES001`, `RES002`, `SYN001`, `SYN002`, `SYN003`, `SYN005`, `SYN006`, `THR001`–`THR004`, `UNS001`–`UNS005`, `VER001`–`VER003`) plus a fails/passes example pair. | +| `explain` | `{ code: string }` | Long-form explanation for any stable diagnostic code (`ALI001`, `ALI002`, `ALI003`, `BS001`, `BS002`, `CAP001`–`CAP003`, `DEP001`–`DEP004`, `EFF002`–`EFF004`, `FMT001`, `INT001`–`INT005`, `MAT001`–`MAT004`, `RES001`, `RES002`, `SYN001`, `SYN002`, `SYN003`, `SYN004`, `SYN005`, `SYN006`, `THR001`–`THR004`, `UNS001`–`UNS005`, `VER001`–`VER003`) plus a fails/passes example pair. | A bot's loop becomes deterministic: `transform` → if `ok=false`, read `diagnostics[0].code` → `explain(code)` → apply `rewrite` → `transform` again. diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index d331091d..ed156317 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -544,6 +544,37 @@ const E: Record = { " unsafe \"stdout.write returns void\" { stdout.write(`Hello, ${name}`) }\n" + "}", }, + SYN004: { + code: "SYN004", + title: "eval() or new Function() call bypasses all static capability and syntax checks", + rule: + "`eval(...)` and `new Function(...)` execute strings as code at runtime — " + + "no static analysis can see what they do; every capability check (CAP001/CAP002), " + + "resource declaration (reads/writes), and safety check (SYN002/SYN003) can be bypassed " + + "by routing the unsafe pattern through eval", + 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 \"\" { }` to make the escape hatch visible in the diff", + rewrite: + "// before — eval hides config key access from static analysis\n" + + "fn getConfig(key: string) -> string {\n" + + " return eval('process.env.' + key) // SYN004\n" + + "}\n\n" + + "// after — explicit parameter, no eval\n" + + "fn getConfig(value: string) -> string {\n" + + " return value\n" + + "}", + example: + "// SYN004: eval bypasses all static checks\n" + + "fn run(code: string) -> string {\n" + + " return eval(code)\n" + + "}\n\n" + + "// fix: suppress with unsafe if eval is genuinely needed\n" + + "fn run(code: string) -> string {\n" + + ' return unsafe "evaluates user-provided script in sandbox" { eval(code) }\n' + + "}", + }, SYN005: { code: "SYN005", title: "process.env access is an undeclared deployment environment dependency", diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index eb1c94ed..4580aa1e 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -53,10 +53,11 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const warnings: Diagnostic[] = []; const syn002 = getErrorCode("SYN002")!; const syn003 = getErrorCode("SYN003")!; + const syn004 = getErrorCode("SYN004")!; const syn005 = getErrorCode("SYN005")!; const syn006 = getErrorCode("SYN006")!; - // Collect char-offset ranges where SYN002/SYN003/SYN005/SYN006 are suppressed: + // Collect char-offset ranges where SYN002/SYN003/SYN004/SYN005/SYN006 are suppressed: // 1. `unsafe "reason" { ... }` expression blocks — explicit acknowledgment. // 2. `unsafe "reason" fn` bodies — the entire body is exempt, including any // non-unsafe nested fns declared inside it (matching uns-check's pattern). @@ -70,7 +71,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const nesting = computeNesting(program.fns.map((f) => f.decl)); 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. // The range-based suppression above also covers nested non-unsafe fns within it, // so this early-continue is kept purely as an optimisation. if (decl.unsafeReason !== undefined) continue; @@ -265,6 +266,103 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult }); } + // SYN004: eval() and new Function() call detection. + // Fires on: + // eval(...) — global eval not preceded by `.`/`?.`, followed by `(` + // new Function(…) — token `new` followed by `Function` followed by `(` + // Suppressed inside `unsafe { }` blocks and `unsafe fn` bodies. + // `.eval(...)` (method call on a local), `Function.*` member accesses, and + // bare `Function` references not preceded by `new` are NOT flagged. + let nextInner4 = 0; + const open4: typeof inner = []; + for (let i = bodyStart; i < decl.tokenEnd; i++) { + while (open4.length > 0 && open4[open4.length - 1]!.tokenEnd <= i) open4.pop(); + while (nextInner4 < inner.length && inner[nextInner4]!.tokenStart <= i) { + open4.push(inner[nextInner4]!); + nextInner4++; + } + if (open4.length > 0) continue; + + const tok4 = tokens[i]; + if (!tok4 || tok4.kind !== "ident") continue; + + // --- eval(...) detection --- + if (tok4.text === "eval") { + // Exclude: `obj.eval(...)` — preceded by `.` or `?.` + const prevIdx4 = prevSignificant(tokens, i - 1); + const prev4 = tokens[prevIdx4]; + if (prev4 && ((prev4.kind === "punct" && prev4.text === ".") || prev4.kind === "questionDot")) + continue; + + // 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; + + if (isInsideRange(tok4.start, unsafeRanges)) continue; + + const callSep4 = isOptEval ? "?." : ""; + const loc4 = locationOf(src, tok4.start); + warnings.push({ + code: "SYN004", + severity: "warning", + file: null, + line: loc4.line, + column: loc4.column, + start: tok4.start, + end: callTok4.start + 1, + 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" { }`, + rule: syn004.rule, + idiom: syn004.idiom, + rewrite: syn004.rewrite, + }); + continue; + } + + // --- new Function(...) detection --- + if (tok4.text === "Function") { + // Must be preceded by `new`. + const prevIdx4 = prevSignificant(tokens, i - 1); + const prev4 = tokens[prevIdx4]; + if (!prev4 || prev4.kind !== "ident" || prev4.text !== "new") continue; + + // Must be followed by `(`. + const nextIdx4 = nextSignificant(tokens, i + 1); + const callTok4 = tokens[nextIdx4]; + if (!callTok4 || !(callTok4.kind === "open" && callTok4.text === "(")) continue; + + if (isInsideRange(tok4.start, unsafeRanges)) continue; + + const loc4 = locationOf(src, prev4.start); + warnings.push({ + code: "SYN004", + severity: "warning", + file: null, + line: loc4.line, + column: loc4.column, + start: prev4.start, + end: callTok4.start + 1, + message: + `fn '${decl.name}' constructs new Function() — ` + + `the Function constructor executes a string as code and bypasses all static checks; ` + + `refactor to explicit code or wrap in unsafe "reason" { }`, + rule: syn004.rule, + idiom: syn004.idiom, + rewrite: syn004.rewrite, + }); + } + } + // SYN005: process.env access detection. // Fires on any `process.env` access (read or write) — not just calls. // Detection: `process` not preceded by `.`/`?.`, followed by `.`/`?.` then `env`. diff --git a/packages/compiler/tests/error-codes.test.ts b/packages/compiler/tests/error-codes.test.ts index 8864a5d4..5b57aae2 100644 --- a/packages/compiler/tests/error-codes.test.ts +++ b/packages/compiler/tests/error-codes.test.ts @@ -15,7 +15,7 @@ describe("error-code registry", () => { "INT001", "INT002", "INT003", "INT004", "INT005", "MAT001", "MAT002", "MAT003", "MAT004", "RES001", "RES002", - "SYN001", "SYN002", "SYN003", "SYN005", "SYN006", + "SYN001", "SYN002", "SYN003", "SYN004", "SYN005", "SYN006", "THR001", "THR002", "THR003", "THR004", "UNS001", "UNS002", "UNS003", "UNS004", "UNS005", "VER001", "VER002", "VER003", diff --git a/packages/compiler/tests/syn004-check.test.ts b/packages/compiler/tests/syn004-check.test.ts new file mode 100644 index 00000000..1b4c0ef8 --- /dev/null +++ b/packages/compiler/tests/syn004-check.test.ts @@ -0,0 +1,131 @@ +/** + * Tests for SYN004: eval() and new Function() call detection (?bs 0.7+). + */ + +import { describe, expect, it } from "vitest"; +import { transform } from "../src/transform.js"; + +describe("SYN004: eval() and new Function() checks (0.7+)", () => { + it("fires on eval() call in fn body", () => { + const src = + "?bs 0.7\n" + + "fn run(code: string) -> string {\n" + + " return eval(code)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN004")).toBe(true); + }); + + it("fires on eval() with a string literal", () => { + const src = + "?bs 0.7\n" + + "fn run() -> number {\n" + + " return eval('1 + 2')\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN004")).toBe(true); + }); + + it("fires on new Function() call", () => { + const src = + "?bs 0.7\n" + + "fn build(body: string) -> Function {\n" + + " return new Function(body)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN004")).toBe(true); + }); + + it("fires on new Function() with multiple args", () => { + const src = + "?bs 0.7\n" + + "fn build(a: string, body: string) -> Function {\n" + + " return new Function(a, body)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN004")).toBe(true); + }); + + it("does not fire below ?bs 0.7", () => { + const src = + "?bs 0.2\n" + + "fn run(code) {\n" + + " return eval(code)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN004")).toBe(false); + }); + + it("does not fire when eval is inside an unsafe block", () => { + const src = + "?bs 0.7\n" + + "fn run(code: string) -> string {\n" + + ' return unsafe "evaluates user script in sandbox" { eval(code) }\n' + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN004")).toBe(false); + }); + + it("does not fire when new Function is inside an unsafe block", () => { + const src = + "?bs 0.7\n" + + "fn build(body: string) -> Function {\n" + + ' return unsafe "trusted function factory" { new Function(body) }\n' + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN004")).toBe(false); + }); + + it("does not fire when eval is inside an unsafe fn body", () => { + const src = + "?bs 0.7\n" + + 'unsafe "runs untrusted user scripts" fn sandbox(code: string) -> string {\n' + + " return eval(code)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN004")).toBe(false); + }); + + it("does not fire on .eval() — method call on a local object", () => { + const src = + "?bs 0.7\n" + + "fn run(vm: { eval: (s: string) -> string }) -> string {\n" + + " return vm.eval('code')\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN004")).toBe(false); + }); + + it("does not fire on Function.prototype.call — not new Function()", () => { + const src = + "?bs 0.7\n" + + "fn run(f: Function) -> void {\n" + + " Function.prototype.call(f)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN004")).toBe(false); + }); + + it("does not fire on bare Function reference — not called with new", () => { + const src = + "?bs 0.7\n" + + "fn check(f: unknown) -> boolean {\n" + + " return typeof Function !== 'undefined'\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN004")).toBe(false); + }); + + it("has severity 'warning' (non-blocking — transform must not throw)", () => { + const src = + "?bs 0.7\n" + + "fn run(code: string) -> string {\n" + + " return eval(code)\n" + + "}\n"; + let result: ReturnType; + expect(() => { result = transform(src); }).not.toThrow(); + const w = result!.warnings.find((w) => w.code === "SYN004"); + expect(w).toBeDefined(); + expect(w!.severity).toBe("warning"); + }); +}); diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index ea3797b3..cdbb0df4 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -739,6 +739,41 @@ export const EXPLANATIONS: Readonly> = { "}\n", }, }, + SYN004: { + code: "SYN004", + title: "eval() or new Function() bypasses all static capability and syntax checks", + body: + "Botscript's safety model relies on static analysis: capability declarations, resource " + + "labels, and syntax checks (SYN002/SYN003) all operate on visible, unchanging source text. " + + "`eval(...)` and `new Function(...)` shatter that foundation — they execute arbitrary " + + "strings as code at runtime, and no static pass can see what those strings will do.\n\n" + + "The risk in bot code is concrete:\n" + + "- `eval('process.env.' + key)` bypasses SYN005 (env dependency hidden from callers)\n" + + "- `eval('http.get(...)')` bypasses CAP001 (capability claim not in the fn's header)\n" + + "- `new Function('return process.exit(1)')()` bypasses SYN003 and the Result contract\n\n" + + "Every other SYN check is weakened by eval: a bot could route any unsafe pattern through " + + "`eval` to avoid static detection. The capability manifest hash proves the *source* hasn't " + + "changed, not that runtime behaviour is bounded.\n\n" + + "**Fix:** refactor the eval-based pattern to use explicit code paths. If the use case " + + "genuinely requires eval-level dynamism (e.g. a sandboxed user-script interpreter), " + + "wrap the call in `unsafe \"\" { eval(src) }` to make the escape hatch visible " + + "in the diff and in code review.\n\n" + + "SYN004 fires at `?bs 0.7+` as a non-blocking warning. Detection is token-based: " + + "`eval` not preceded by `.`/`?.` followed by `(`, and `new Function` followed by `(`. " + + "`.eval(...)` (method call on a local object) and `Function.*` member accesses are excluded.", + example: { + fails: + "?bs 0.7\n" + + "fn run(code: string) -> string {\n" + + " return eval(code)\n" + + "}\n", + passes: + "?bs 0.7\n" + + "fn run(code: string) -> string {\n" + + " return unsafe \"evaluates user-provided script in sandbox\" { eval(code) }\n" + + "}\n", + }, + }, SYN005: { code: "SYN005", title: "process.env access is an undeclared deployment environment dependency", diff --git a/packages/mcp/tests/server.test.ts b/packages/mcp/tests/server.test.ts index 0cec4df6..4f4d3622 100644 --- a/packages/mcp/tests/server.test.ts +++ b/packages/mcp/tests/server.test.ts @@ -75,6 +75,7 @@ describe("botscript-mcp explanations", () => { "SYN001", "SYN002", "SYN003", + "SYN004", "SYN005", "SYN006", "THR001", From 881e2c8459e812e41e23b9e4acdb396f7e4fc3fb Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Mon, 8 Jun 2026 23:28:15 -0300 Subject: [PATCH 02/11] fix(syn004): detect bare Function() calls without new; fix SYN005 xref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- packages/compiler/src/passes/syn-check.ts | 30 ++++++++++++-------- packages/compiler/tests/syn004-check.test.ts | 12 +++++++- packages/mcp/src/explanations.ts | 2 +- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 4580aa1e..766b51d3 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -266,13 +266,13 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult }); } - // SYN004: eval() and new Function() call detection. + // SYN004: eval() and Function() / new Function() call detection. // Fires on: - // eval(...) — global eval not preceded by `.`/`?.`, followed by `(` - // new Function(…) — token `new` followed by `Function` followed by `(` + // eval(...) — global eval not preceded by `.`/`?.`, followed by `(` + // Function(…) — bare Function call not preceded by `.`/`?.`, followed by `(` + // new Function(…) — same as bare Function call, `new` prefix only affects message // Suppressed inside `unsafe { }` blocks and `unsafe fn` bodies. - // `.eval(...)` (method call on a local), `Function.*` member accesses, and - // bare `Function` references not preceded by `new` are NOT flagged. + // `.eval(...)` (method call on a local) and `Function.*` member accesses are NOT flagged. let nextInner4 = 0; const open4: typeof inner = []; for (let i = bodyStart; i < decl.tokenEnd; i++) { @@ -329,31 +329,37 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult continue; } - // --- new Function(...) detection --- + // --- new Function(...) / Function(...) detection --- + // Both `new Function(body)` and bare `Function(body)` execute a string as + // code at runtime and are equivalent bypasses. if (tok4.text === "Function") { - // Must be preceded by `new`. const prevIdx4 = prevSignificant(tokens, i - 1); const prev4 = tokens[prevIdx4]; - if (!prev4 || prev4.kind !== "ident" || prev4.text !== "new") continue; - // Must be followed by `(`. + // Exclude: `obj.Function(...)` — preceded by `.` or `?.` + if (prev4 && ((prev4.kind === "punct" && prev4.text === ".") || prev4.kind === "questionDot")) + continue; + + // Exclude: `Function.prototype.*` — followed by `.` (member access, not a call) const nextIdx4 = nextSignificant(tokens, i + 1); const callTok4 = tokens[nextIdx4]; if (!callTok4 || !(callTok4.kind === "open" && callTok4.text === "(")) continue; if (isInsideRange(tok4.start, unsafeRanges)) continue; - const loc4 = locationOf(src, prev4.start); + const hasNew = prev4 && prev4.kind === "ident" && prev4.text === "new"; + const warnStart = hasNew ? prev4!.start : tok4.start; + const loc4 = locationOf(src, warnStart); warnings.push({ code: "SYN004", severity: "warning", file: null, line: loc4.line, column: loc4.column, - start: prev4.start, + start: warnStart, end: callTok4.start + 1, message: - `fn '${decl.name}' constructs new Function() — ` + + `fn '${decl.name}' constructs ${hasNew ? "new " : ""}Function() — ` + `the Function constructor executes a string as code and bypasses all static checks; ` + `refactor to explicit code or wrap in unsafe "reason" { }`, rule: syn004.rule, diff --git a/packages/compiler/tests/syn004-check.test.ts b/packages/compiler/tests/syn004-check.test.ts index 1b4c0ef8..968a5146 100644 --- a/packages/compiler/tests/syn004-check.test.ts +++ b/packages/compiler/tests/syn004-check.test.ts @@ -106,7 +106,17 @@ describe("SYN004: eval() and new Function() checks (0.7+)", () => { expect(result.warnings.some((w) => w.code === "SYN004")).toBe(false); }); - it("does not fire on bare Function reference — not called with new", () => { + it("fires on bare Function() call without new — equivalent runtime bypass", () => { + const src = + "?bs 0.7\n" + + "fn build(body: string) -> unknown {\n" + + " return Function(body)()\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN004")).toBe(true); + }); + + it("does not fire on bare Function reference — not called", () => { const src = "?bs 0.7\n" + "fn check(f: unknown) -> boolean {\n" + diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index cdbb0df4..06d269c4 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -748,7 +748,7 @@ export const EXPLANATIONS: Readonly> = { "`eval(...)` and `new Function(...)` shatter that foundation — they execute arbitrary " + "strings as code at runtime, and no static pass can see what those strings will do.\n\n" + "The risk in bot code is concrete:\n" + - "- `eval('process.env.' + key)` bypasses SYN005 (env dependency hidden from callers)\n" + + "- `eval('process.env.' + key)` hides env dependencies from callers (invisible to static analysis)\n" + "- `eval('http.get(...)')` bypasses CAP001 (capability claim not in the fn's header)\n" + "- `new Function('return process.exit(1)')()` bypasses SYN003 and the Result contract\n\n" + "Every other SYN check is weakened by eval: a bot could route any unsafe pattern through " + From 814a4601fab9d5f76c018f15bae9396d71b4a67a Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Tue, 9 Jun 2026 03:28:31 -0300 Subject: [PATCH 03/11] fix(syn004): detect Function?.() optional call; align docs/tests with bare Function() detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- AGENTS.md | 2 +- packages/compiler/src/error-codes.ts | 6 +++--- packages/compiler/src/passes/syn-check.ts | 13 +++++++++++-- packages/compiler/tests/syn004-check.test.ts | 14 ++++++++++++-- packages/mcp/src/explanations.ts | 13 +++++++------ 5 files changed, 34 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bec8e4ee..4c4b8f33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -198,7 +198,7 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope. | SYN001 | Duplicate fn header clause (e.g. two `reads { }` on the same fn, or two `intent:`, or two `throws {}`), or a label inside `reads {}` / `writes {}` / `throws {}` that is not a plain identifier. `parseFn` is version-agnostic, so SYN001 fires whenever a duplicate clause is written regardless of the `?bs` pin. | Declare each header clause once; merge label lists rather than repeating the clause; use bare identifiers (not quoted strings) as labels. | | SYN002 | (0.7+, warning) A fn body contains a native `throw` statement. Native throws bypass botscript's Result-based error contract: callers using `?` unwrap or `match` on Result will not observe exceptions raised via `throw`. | Replace `throw new ErrorType(...)` with `return err(new ErrorType(...))` and update the return type to `Result`. | | 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 }`. | -| SYN004 | (0.7+, warning) A fn body calls `eval(...)` (global eval not preceded by `.`/`?.`) or constructs `new Function(...)`. Both 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. 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 "" { eval(...) }`. | +| 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 "" { 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 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" { }`. | | SYN006 | (0.7+, warning) A fn body calls `process.exit()`, `process?.exit()`, or `process.exit?.()`. All forms terminate the entire host process — not just the fn, not just the bot. They produce no return value, bypass `Result` propagation, `throws {}`, `match`, and any caller recovery path. No capability declaration covers them. Detection: `process` not preceded by `.`/`?.`, followed by `.`/`?.` then `exit` then `(` or `?.(`. `obj.process.exit(...)`, `process.exit` without `(`, and `process.exitCode` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Return `err(...)` and let the caller decide whether to terminate. If `process.exit` is genuinely required at a bootstrap entry point, wrap in `unsafe "exits on invalid config" { process.exit(1) }`. | | INT002 | (0.7+) A fn declares `intent: "pure"` but its body directly references a stdlib capability (e.g. `http.get`, `fs.read`). Pure intent is enforced at the body level as well as the header. | Remove the stdlib call from the body, or change the intent. | diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index ed156317..dc308143 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -546,12 +546,12 @@ const E: Record = { }, SYN004: { code: "SYN004", - title: "eval() or new Function() call bypasses all static capability and syntax checks", + title: "eval() or Function() / new Function() call bypasses all static capability and syntax checks", rule: - "`eval(...)` and `new Function(...)` execute strings as code at runtime — " + + "`eval(...)`, `Function(...)`, and `new Function(...)` execute strings as code at runtime — " + "no static analysis can see what they do; every capability check (CAP001/CAP002), " + "resource declaration (reads/writes), and safety check (SYN002/SYN003) can be bypassed " + - "by routing the unsafe pattern through eval", + "by routing the unsafe pattern through eval or the Function constructor", 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), " + diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 766b51d3..6ba46c81 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -341,13 +341,22 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult continue; // 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 callTok4 = tokens[nextIdx4]; + 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; if (isInsideRange(tok4.start, unsafeRanges)) continue; const hasNew = prev4 && prev4.kind === "ident" && prev4.text === "new"; + const funcCallSep = isOptFunc ? "?." : ""; const warnStart = hasNew ? prev4!.start : tok4.start; const loc4 = locationOf(src, warnStart); warnings.push({ @@ -359,7 +368,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult start: warnStart, end: callTok4.start + 1, message: - `fn '${decl.name}' constructs ${hasNew ? "new " : ""}Function() — ` + + `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" { }`, rule: syn004.rule, diff --git a/packages/compiler/tests/syn004-check.test.ts b/packages/compiler/tests/syn004-check.test.ts index 968a5146..f5523d8a 100644 --- a/packages/compiler/tests/syn004-check.test.ts +++ b/packages/compiler/tests/syn004-check.test.ts @@ -1,11 +1,11 @@ /** - * Tests for SYN004: eval() and new Function() call detection (?bs 0.7+). + * Tests for SYN004: eval(), Function(), and new Function() call detection (?bs 0.7+). */ import { describe, expect, it } from "vitest"; import { transform } from "../src/transform.js"; -describe("SYN004: eval() and new Function() checks (0.7+)", () => { +describe("SYN004: eval(), Function(), and new Function() checks (0.7+)", () => { it("fires on eval() call in fn body", () => { const src = "?bs 0.7\n" + @@ -116,6 +116,16 @@ describe("SYN004: eval() and new Function() checks (0.7+)", () => { expect(result.warnings.some((w) => w.code === "SYN004")).toBe(true); }); + it("fires on Function?.() optional call form in fn body", () => { + const src = + "?bs 0.7\n" + + "fn build(body: string) -> unknown {\n" + + " return Function?.(body)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN004")).toBe(true); + }); + it("does not fire on bare Function reference — not called", () => { const src = "?bs 0.7\n" + diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index 06d269c4..e154c20f 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -741,25 +741,26 @@ export const EXPLANATIONS: Readonly> = { }, SYN004: { code: "SYN004", - title: "eval() or new Function() bypasses all static capability and syntax checks", + title: "eval() or Function() / new Function() bypasses all static capability and syntax checks", body: "Botscript's safety model relies on static analysis: capability declarations, resource " + "labels, and syntax checks (SYN002/SYN003) all operate on visible, unchanging source text. " + - "`eval(...)` and `new Function(...)` shatter that foundation — they execute arbitrary " + - "strings as code at runtime, and no static pass can see what those strings will do.\n\n" + + "`eval(...)`, `Function(...)`, and `new Function(...)` shatter that foundation — they " + + "execute arbitrary strings as code at runtime, and no static pass can see what those strings will do.\n\n" + "The risk in bot code is concrete:\n" + "- `eval('process.env.' + key)` hides env dependencies from callers (invisible to static analysis)\n" + "- `eval('http.get(...)')` bypasses CAP001 (capability claim not in the fn's header)\n" + "- `new Function('return process.exit(1)')()` bypasses SYN003 and the Result contract\n\n" + "Every other SYN check is weakened by eval: a bot could route any unsafe pattern through " + - "`eval` to avoid static detection. The capability manifest hash proves the *source* hasn't " + - "changed, not that runtime behaviour is bounded.\n\n" + + "`eval` or the Function constructor to avoid static detection. The capability manifest hash " + + "proves the *source* hasn't changed, not that runtime behavior is bounded.\n\n" + "**Fix:** refactor the eval-based pattern to use explicit code paths. If the use case " + "genuinely requires eval-level dynamism (e.g. a sandboxed user-script interpreter), " + "wrap the call in `unsafe \"\" { eval(src) }` to make the escape hatch visible " + "in the diff and in code review.\n\n" + "SYN004 fires at `?bs 0.7+` as a non-blocking warning. Detection is token-based: " + - "`eval` not preceded by `.`/`?.` followed by `(`, and `new Function` followed by `(`. " + + "`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.", example: { fails: From 229111f2471a0661c4155ea39dd56099a774f525 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Tue, 9 Jun 2026 07:31:53 -0300 Subject: [PATCH 04/11] fix(syn004): exclude declaration contexts from eval/Function detection; fix message text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- packages/compiler/src/error-codes.ts | 2 +- packages/compiler/src/passes/syn-check.ts | 28 ++++++++++++++++++-- packages/compiler/tests/syn004-check.test.ts | 24 +++++++++++++++++ packages/mcp/src/explanations.ts | 2 +- 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index dc308143..917a142a 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -546,7 +546,7 @@ const E: Record = { }, SYN004: { code: "SYN004", - title: "eval() or Function() / new Function() call bypasses all static capability and syntax checks", + title: "eval() or Function() / new Function() calls bypass all static capability and syntax checks", rule: "`eval(...)`, `Function(...)`, and `new Function(...)` execute strings as code at runtime — " + "no static analysis can see what they do; every capability check (CAP001/CAP002), " + diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 6ba46c81..a34c0a99 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -306,6 +306,19 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const callTok4 = tokens[callIdx4]; if (!callTok4 || !(callTok4.kind === "open" && callTok4.text === "(")) continue; + // Exclude declarations: `function eval(params) {}` and method shorthands `{ eval(params) {} }`. + // If the token after the matching `)` is `{`, `:` (return-type annotation), or `=>`, + // this is a declaration, not a call expression. + 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; + } + if (isInsideRange(tok4.start, unsafeRanges)) continue; const callSep4 = isOptEval ? "?." : ""; @@ -321,7 +334,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult 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" { }`, + `resource, and safety checks; refactor to explicit code or wrap in unsafe "reason" { eval(src) }`, rule: syn004.rule, idiom: syn004.idiom, rewrite: syn004.rewrite, @@ -353,6 +366,17 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const callTok4 = tokens[callIdx4]; if (!callTok4 || !(callTok4.kind === "open" && callTok4.text === "(")) continue; + // Exclude declarations: `function Function(params) {}` and method shorthands. + 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; + } + if (isInsideRange(tok4.start, unsafeRanges)) continue; const hasNew = prev4 && prev4.kind === "ident" && prev4.text === "new"; @@ -370,7 +394,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult 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" { }`, + `refactor to explicit code or wrap in unsafe "reason" { new Function(body) }`, rule: syn004.rule, idiom: syn004.idiom, rewrite: syn004.rewrite, diff --git a/packages/compiler/tests/syn004-check.test.ts b/packages/compiler/tests/syn004-check.test.ts index f5523d8a..06167011 100644 --- a/packages/compiler/tests/syn004-check.test.ts +++ b/packages/compiler/tests/syn004-check.test.ts @@ -136,6 +136,30 @@ describe("SYN004: eval(), Function(), and new Function() checks (0.7+)", () => { expect(result.warnings.some((w) => w.code === "SYN004")).toBe(false); }); + it("does not fire on function declaration named eval — declaration not a call", () => { + const src = + "?bs 0.7\n" + + "fn run(code: string) -> string {\n" + + " function eval(src: string) { return src }\n" + + " return eval(code)\n" + + "}\n"; + const result = transform(src); + // Only one SYN004: the call `eval(code)`, not the declaration `function eval(src) {...}` + const warnings = result.warnings.filter((w) => w.code === "SYN004"); + expect(warnings.length).toBe(1); + }); + + it("does not fire on method shorthand named Function — declaration not a call", () => { + const src = + "?bs 0.7\n" + + "fn run() -> unknown {\n" + + " const obj = { Function(body: string) { return body } }\n" + + " return obj\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN004")).toBe(false); + }); + it("has severity 'warning' (non-blocking — transform must not throw)", () => { const src = "?bs 0.7\n" + diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index e154c20f..2d893116 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -750,7 +750,7 @@ export const EXPLANATIONS: Readonly> = { "The risk in bot code is concrete:\n" + "- `eval('process.env.' + key)` hides env dependencies from callers (invisible to static analysis)\n" + "- `eval('http.get(...)')` bypasses CAP001 (capability claim not in the fn's header)\n" + - "- `new Function('return process.exit(1)')()` bypasses SYN003 and the Result contract\n\n" + + "- `new Function('return process.exit(1)')()` hides arbitrary effects from all static checks (capability, Result contract, and every SYN diagnostic)\n\n" + "Every other SYN check is weakened by eval: a bot could route any unsafe pattern through " + "`eval` or the Function constructor to avoid static detection. The capability manifest hash " + "proves the *source* hasn't changed, not that runtime behavior is bounded.\n\n" + From 5f37a1e21ab2722c093055418985a11e4d9fea82 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Tue, 9 Jun 2026 11:29:00 -0300 Subject: [PATCH 05/11] fix(compiler): SYN004 Function warning suggests correct unsafe-block 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 --- packages/compiler/src/passes/syn-check.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index a34c0a99..f7099386 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -394,7 +394,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult 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) }`, + `refactor to explicit code or wrap in unsafe "reason" { ${hasNew ? "new Function(body)" : "Function(body)"} }`, rule: syn004.rule, idiom: syn004.idiom, rewrite: syn004.rewrite, From 6f03fb0d8c647a381d0313315178317e09f04692 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Tue, 9 Jun 2026 15:29:54 -0300 Subject: [PATCH 06/11] fix(syn004): detect TypeScript instantiation forms eval() and Function() Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/syn-check.ts | 36 ++++++++++++++++++-- packages/compiler/tests/syn004-check.test.ts | 30 ++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index f7099386..8eec212d 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -294,7 +294,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult if (prev4 && ((prev4.kind === "punct" && prev4.text === ".") || prev4.kind === "questionDot")) continue; - // Must be followed by `(` (direct call) or `?.(` (optional call). + // Must be followed by `(` (direct call), `?.(` (optional call), or `(` (TS instantiation). const nextIdx4 = nextSignificant(tokens, i + 1); const next4 = tokens[nextIdx4]; let isOptEval = false; @@ -302,6 +302,22 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult if (next4 && next4.kind === "questionDot") { isOptEval = true; callIdx4 = nextSignificant(tokens, nextIdx4 + 1); + } else if (next4 && next4.kind === "operator" && next4.text === "<") { + // TypeScript instantiation form: eval(...) + 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; } const callTok4 = tokens[callIdx4]; if (!callTok4 || !(callTok4.kind === "open" && callTok4.text === "(")) continue; @@ -354,7 +370,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult continue; // Exclude: `Function.prototype.*` — followed by `.` (member access, not a call) - // Must be followed by `(` (direct call) or `?.(` (optional call). + // Must be followed by `(` (direct call), `?.(` (optional call), or `(` (TS instantiation). const nextIdx4 = nextSignificant(tokens, i + 1); const next4 = tokens[nextIdx4]; let isOptFunc = false; @@ -362,6 +378,22 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult if (next4 && next4.kind === "questionDot") { isOptFunc = true; callIdx4 = nextSignificant(tokens, nextIdx4 + 1); + } else if (next4 && next4.kind === "operator" && next4.text === "<") { + // TypeScript instantiation form: Function(...) / new Function(...) + 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; } const callTok4 = tokens[callIdx4]; if (!callTok4 || !(callTok4.kind === "open" && callTok4.text === "(")) continue; diff --git a/packages/compiler/tests/syn004-check.test.ts b/packages/compiler/tests/syn004-check.test.ts index 06167011..c57f7304 100644 --- a/packages/compiler/tests/syn004-check.test.ts +++ b/packages/compiler/tests/syn004-check.test.ts @@ -172,4 +172,34 @@ describe("SYN004: eval(), Function(), and new Function() checks (0.7+)", () => { expect(w).toBeDefined(); expect(w!.severity).toBe("warning"); }); + + it("fires on eval() TypeScript instantiation form", () => { + const src = + "?bs 0.7\n" + + "fn run(code: string) -> unknown {\n" + + " return eval(code)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN004")).toBe(true); + }); + + it("fires on new Function() TypeScript instantiation form", () => { + const src = + "?bs 0.7\n" + + "fn build(body: string) -> unknown {\n" + + " return new Function(body)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN004")).toBe(true); + }); + + it("fires on Function() bare instantiation form", () => { + const src = + "?bs 0.7\n" + + "fn build(body: string) -> unknown {\n" + + " return Function(body)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN004")).toBe(true); + }); }); From 9aa7bb4052df8b0de8bd5c727f44f22b037e4565 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Tue, 9 Jun 2026 19:34:39 -0300 Subject: [PATCH 07/11] fix(syn004): remove colon from declaration-exclusion check; add ternary 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 --- packages/compiler/src/passes/syn-check.ts | 10 ++++++---- packages/compiler/tests/syn004-check.test.ts | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 8eec212d..458358fc 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -323,14 +323,15 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult if (!callTok4 || !(callTok4.kind === "open" && callTok4.text === "(")) continue; // Exclude declarations: `function eval(params) {}` and method shorthands `{ eval(params) {} }`. - // If the token after the matching `)` is `{`, `:` (return-type annotation), or `=>`, - // this is a declaration, not a call expression. + // 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 === "punct" && afterClose.text === ":") || afterClose.kind === "fatArrow" )) continue; } @@ -399,12 +400,13 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult if (!callTok4 || !(callTok4.kind === "open" && callTok4.text === "(")) continue; // 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 === "punct" && afterClose.text === ":") || afterClose.kind === "fatArrow" )) continue; } diff --git a/packages/compiler/tests/syn004-check.test.ts b/packages/compiler/tests/syn004-check.test.ts index c57f7304..23c891d5 100644 --- a/packages/compiler/tests/syn004-check.test.ts +++ b/packages/compiler/tests/syn004-check.test.ts @@ -160,6 +160,26 @@ describe("SYN004: eval(), Function(), and new Function() checks (0.7+)", () => { expect(result.warnings.some((w) => w.code === "SYN004")).toBe(false); }); + it("fires on eval() in a ternary then-branch — not confused with a method signature", () => { + const src = + "?bs 0.7\n" + + "fn run(flag: boolean, code: string) -> string {\n" + + " return flag ? eval(code) : code\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN004")).toBe(true); + }); + + it("fires on Function() in a ternary else-branch — not confused with a method signature", () => { + const src = + "?bs 0.7\n" + + "fn run(flag: boolean, body: string) -> unknown {\n" + + " return flag ? body : Function(body)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN004")).toBe(true); + }); + it("has severity 'warning' (non-blocking — transform must not throw)", () => { const src = "?bs 0.7\n" + From 77af63ca0f119e3a47181e23ee7c2c2b7ecaddd0 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Tue, 9 Jun 2026 23:46:45 -0300 Subject: [PATCH 08/11] fix(syn004): handle return-type-annotated declarations; guard Function ternary then-branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- packages/compiler/src/passes/syn-check.ts | 24 ++++++----- packages/compiler/tests/syn004-check.test.ts | 43 ++++++++++++++++++++ packages/mcp/src/explanations.ts | 2 +- 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 458358fc..37535327 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -322,17 +322,18 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const callTok4 = tokens[callIdx4]; if (!callTok4 || !(callTok4.kind === "open" && callTok4.text === "(")) continue; - // 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. + // Exclude declarations: `function eval(params) {}`, `{ eval(params) {} }`, and + // return-type-annotated forms `function eval(params): T {}` / `{ eval(params): T {} }`. + // The `:` check is guarded: in a ternary (`? eval(x) : y`), the token before `eval` + // is `?` (question) — that is a call, not a declaration, so `:` is skipped there. if (callTok4.matchedAt !== undefined) { const afterCloseIdx = nextSignificant(tokens, callTok4.matchedAt + 1); const afterClose = tokens[afterCloseIdx]; + const isTernaryConsequent = prev4 && prev4.kind === "question"; if (afterClose && ( (afterClose.kind === "open" && afterClose.text === "{") || - afterClose.kind === "fatArrow" + afterClose.kind === "fatArrow" || + (!isTernaryConsequent && afterClose.kind === "punct" && afterClose.text === ":") )) continue; } @@ -399,15 +400,18 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const callTok4 = tokens[callIdx4]; if (!callTok4 || !(callTok4.kind === "open" && callTok4.text === "(")) continue; - // 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`). + // Exclude declarations: `function Function(params) {}`, method shorthands, and + // return-type-annotated forms `function Function(params): T {}` / `{ Function(params): T {} }`. + // Guard `:` against ternary then-branch: `? Function(body) : fallback` has `?` before + // Function — that is a call, not a declaration, so `:` is skipped there. if (callTok4.matchedAt !== undefined) { const afterCloseIdx = nextSignificant(tokens, callTok4.matchedAt + 1); const afterClose = tokens[afterCloseIdx]; + const isTernaryConsequent4 = prev4 && prev4.kind === "question"; if (afterClose && ( (afterClose.kind === "open" && afterClose.text === "{") || - afterClose.kind === "fatArrow" + afterClose.kind === "fatArrow" || + (!isTernaryConsequent4 && afterClose.kind === "punct" && afterClose.text === ":") )) continue; } diff --git a/packages/compiler/tests/syn004-check.test.ts b/packages/compiler/tests/syn004-check.test.ts index 23c891d5..ba1217ea 100644 --- a/packages/compiler/tests/syn004-check.test.ts +++ b/packages/compiler/tests/syn004-check.test.ts @@ -160,6 +160,39 @@ describe("SYN004: eval(), Function(), and new Function() checks (0.7+)", () => { expect(result.warnings.some((w) => w.code === "SYN004")).toBe(false); }); + it("does not fire on function declaration named eval with return-type annotation", () => { + const src = + "?bs 0.7\n" + + "fn run(code: string) -> string {\n" + + " function eval(src: string): string { return src }\n" + + " return code\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN004")).toBe(false); + }); + + it("does not fire on method shorthand named eval with return-type annotation", () => { + const src = + "?bs 0.7\n" + + "fn run() -> unknown {\n" + + " const obj = { eval(src: string): string { return src } }\n" + + " return obj\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN004")).toBe(false); + }); + + it("does not fire on method shorthand named Function with return-type annotation", () => { + const src = + "?bs 0.7\n" + + "fn run() -> unknown {\n" + + " const obj = { Function(body: string): Function { return () => body } }\n" + + " return obj\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN004")).toBe(false); + }); + it("fires on eval() in a ternary then-branch — not confused with a method signature", () => { const src = "?bs 0.7\n" + @@ -180,6 +213,16 @@ describe("SYN004: eval(), Function(), and new Function() checks (0.7+)", () => { expect(result.warnings.some((w) => w.code === "SYN004")).toBe(true); }); + it("fires on Function() in a ternary then-branch — not suppressed by ternary colon", () => { + const src = + "?bs 0.7\n" + + "fn run(flag: boolean, body: string) -> unknown {\n" + + " return flag ? Function(body) : body\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN004")).toBe(true); + }); + it("has severity 'warning' (non-blocking — transform must not throw)", () => { const src = "?bs 0.7\n" + diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index 2d893116..4ea185ef 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -741,7 +741,7 @@ export const EXPLANATIONS: Readonly> = { }, SYN004: { code: "SYN004", - title: "eval() or Function() / new Function() bypasses all static capability and syntax checks", + title: "eval() or Function() / new Function() bypass all static capability and syntax checks", body: "Botscript's safety model relies on static analysis: capability declarations, resource " + "labels, and syntax checks (SYN002/SYN003) all operate on visible, unchanging source text. " + From 303aa54cf260d6724cae10c24220d09bfb0a6163 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 10 Jun 2026 07:31:27 -0300 Subject: [PATCH 09/11] fix(syn004): add eval?.() test; clarify SYN005 AGENTS.md description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- AGENTS.md | 2 +- packages/compiler/tests/syn004-check.test.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 4c4b8f33..c3cf4214 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -199,7 +199,7 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope. | SYN002 | (0.7+, warning) A fn body contains a native `throw` statement. Native throws bypass botscript's Result-based error contract: callers using `?` unwrap or `match` on Result will not observe exceptions raised via `throw`. | Replace `throw new ErrorType(...)` with `return err(new ErrorType(...))` and update the return type to `Result`. | | 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 }`. | | 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 "" { 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 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" { }`. | +| SYN005 | (0.7+, warning) A fn body accesses `process.env`. `process.env` is a global deployment-environment namespace — access is invisible to callers and to static analysis; no capability or resource declaration covers it, so 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" { }`. | | SYN006 | (0.7+, warning) A fn body calls `process.exit()`, `process?.exit()`, or `process.exit?.()`. All forms terminate the entire host process — not just the fn, not just the bot. They produce no return value, bypass `Result` propagation, `throws {}`, `match`, and any caller recovery path. No capability declaration covers them. Detection: `process` not preceded by `.`/`?.`, followed by `.`/`?.` then `exit` then `(` or `?.(`. `obj.process.exit(...)`, `process.exit` without `(`, and `process.exitCode` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Return `err(...)` and let the caller decide whether to terminate. If `process.exit` is genuinely required at a bootstrap entry point, wrap in `unsafe "exits on invalid config" { process.exit(1) }`. | | INT002 | (0.7+) A fn declares `intent: "pure"` but its body directly references a stdlib capability (e.g. `http.get`, `fs.read`). Pure intent is enforced at the body level as well as the header. | Remove the stdlib call from the body, or change the intent. | | INT003 | (0.7+) A fn declares `intent: "idempotent"` but also has `uses { random }` or `uses { time }`. Both capabilities produce different values on each call, making the function non-idempotent. Only `random` and `time` are flagged; other capabilities are not structurally flagged by this check (INT003 is a narrow heuristic, not a proof of idempotence). | Remove `random`/`time` from `uses {}`, or change the intent. | diff --git a/packages/compiler/tests/syn004-check.test.ts b/packages/compiler/tests/syn004-check.test.ts index ba1217ea..7b772442 100644 --- a/packages/compiler/tests/syn004-check.test.ts +++ b/packages/compiler/tests/syn004-check.test.ts @@ -116,6 +116,16 @@ describe("SYN004: eval(), Function(), and new Function() checks (0.7+)", () => { expect(result.warnings.some((w) => w.code === "SYN004")).toBe(true); }); + it("fires on eval?.() optional call form in fn body", () => { + const src = + "?bs 0.7\n" + + "fn run(code: string) -> unknown {\n" + + " return eval?.(code)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN004")).toBe(true); + }); + it("fires on Function?.() optional call form in fn body", () => { const src = "?bs 0.7\n" + From 8e1258d0544745ba2473f530e8ccc4296d25db14 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 10 Jun 2026 11:30:35 -0300 Subject: [PATCH 10/11] fix(syn004): idiom shows content in unsafe block; MCP explanation covers instantiation forms Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/error-codes.ts | 2 +- packages/mcp/src/explanations.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index 917a142a..a1c7c21a 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -555,7 +555,7 @@ const E: Record = { 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 \"\" { }` to make the escape hatch visible in the diff", + "wrap in `unsafe \"\" { eval(...) }` to make the escape hatch visible in the diff", rewrite: "// before — eval hides config key access from static analysis\n" + "fn getConfig(key: string) -> string {\n" + diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index 4ea185ef..7191efac 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -759,8 +759,9 @@ export const EXPLANATIONS: Readonly> = { "wrap the call in `unsafe \"\" { eval(src) }` to make the escape hatch visible " + "in the diff and in code review.\n\n" + "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` not preceded by `.`/`?.` followed by `(`, `?.(`, or `(`; bare `Function(...)` / " + + "`Function?.(...)` / `new Function(...)` — including TypeScript instantiation forms " + + "`eval(...)`, `Function(...)`, and `new Function(...)` — not preceded by `.`/`?.`. " + "`.eval(...)` (method call on a local object) and `Function.*` member accesses are excluded.", example: { fails: From e6c08720980f724371cbcb808e79030c4dc3c252 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 11 Jun 2026 07:38:42 -0300 Subject: [PATCH 11/11] =?UTF-8?q?fix(syn004):=20fire=20on=20new=20Function?= =?UTF-8?q?()=20in=20ternary=20then-branch=20=E2=80=94=20check=20token=20b?= =?UTF-8?q?efore=20new?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/compiler/src/passes/syn-check.ts | 10 +++++++--- packages/compiler/tests/syn004-check.test.ts | 10 ++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 37535327..6e0320bd 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -402,12 +402,16 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult // Exclude declarations: `function Function(params) {}`, method shorthands, and // return-type-annotated forms `function Function(params): T {}` / `{ Function(params): T {} }`. - // Guard `:` against ternary then-branch: `? Function(body) : fallback` has `?` before - // Function — that is a call, not a declaration, so `:` is skipped there. + // Guard `:` against ternary then-branch: `? Function(body) :` has `?` before Function. + // `? new Function(body) :` has `new` before Function but `?` before `new` — check both. if (callTok4.matchedAt !== undefined) { const afterCloseIdx = nextSignificant(tokens, callTok4.matchedAt + 1); const afterClose = tokens[afterCloseIdx]; - const isTernaryConsequent4 = prev4 && prev4.kind === "question"; + const prevBeforeNew4 = (prev4 && prev4.kind === "ident" && prev4.text === "new") + ? tokens[prevSignificant(tokens, prevIdx4 - 1)] + : undefined; + const isTernaryConsequent4 = (prev4 && prev4.kind === "question") || + (prevBeforeNew4 !== undefined && prevBeforeNew4 !== null && prevBeforeNew4.kind === "question"); if (afterClose && ( (afterClose.kind === "open" && afterClose.text === "{") || afterClose.kind === "fatArrow" || diff --git a/packages/compiler/tests/syn004-check.test.ts b/packages/compiler/tests/syn004-check.test.ts index 7b772442..f58d5ecb 100644 --- a/packages/compiler/tests/syn004-check.test.ts +++ b/packages/compiler/tests/syn004-check.test.ts @@ -233,6 +233,16 @@ describe("SYN004: eval(), Function(), and new Function() checks (0.7+)", () => { expect(result.warnings.some((w) => w.code === "SYN004")).toBe(true); }); + it("fires on new Function() in a ternary then-branch — prev is new, not ? directly", () => { + const src = + "?bs 0.7\n" + + "fn run(flag: boolean, body: string) -> unknown {\n" + + " return flag ? new Function(body) : body\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN004")).toBe(true); + }); + it("has severity 'warning' (non-blocking — transform must not throw)", () => { const src = "?bs 0.7\n" +