diff --git a/AGENTS.md b/AGENTS.md index 4663a03..c3cf421 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -198,7 +198,8 @@ 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 }`. | -| 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 "" { eval(...) }`. | +| 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/README.md b/README.md index b1cbc6e..2ce0d9b 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 d331091..a1c7c21 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 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), " + + "resource declaration (reads/writes), and safety check (SYN002/SYN003) can be bypassed " + + "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), " + + "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" + + " 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 eb1c94e..6e0320b 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,184 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult }); } + // SYN004: eval() and Function() / new Function() call detection. + // Fires on: + // 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) and `Function.*` member accesses 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), `?.(` (optional call), or `(` (TS instantiation). + 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); + } 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; + + // 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" || + (!isTernaryConsequent && afterClose.kind === "punct" && afterClose.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" { eval(src) }`, + rule: syn004.rule, + idiom: syn004.idiom, + rewrite: syn004.rewrite, + }); + continue; + } + + // --- 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") { + const prevIdx4 = prevSignificant(tokens, i - 1); + const prev4 = tokens[prevIdx4]; + + // 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) + // Must be followed by `(` (direct call), `?.(` (optional call), or `(` (TS instantiation). + 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); + } 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; + + // 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) :` 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 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" || + (!isTernaryConsequent4 && afterClose.kind === "punct" && afterClose.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({ + code: "SYN004", + severity: "warning", + file: null, + line: loc4.line, + column: loc4.column, + start: warnStart, + end: callTok4.start + 1, + 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)"} }`, + 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 8864a5d..5b57aae 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 0000000..f58d5ec --- /dev/null +++ b/packages/compiler/tests/syn004-check.test.ts @@ -0,0 +1,288 @@ +/** + * 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(), Function(), 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("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("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" + + "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" + + " return typeof Function !== 'undefined'\n" + + "}\n"; + const result = transform(src); + 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("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" + + "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("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("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" + + "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"); + }); + + 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); + }); +}); diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index ea3797b..7191efa 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -739,6 +739,43 @@ export const EXPLANATIONS: Readonly> = { "}\n", }, }, + SYN004: { + code: "SYN004", + 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. " + + "`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)')()` 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" + + "**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 `(`, `?.(`, 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: + "?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 0cec4df..4f4d362 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",