diff --git a/AGENTS.md b/AGENTS.md index 01484c34..d089fc5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -214,6 +214,7 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope. | SYN019 | (0.7+, warning) A fn body calls `crypto.getRandomValues(buf)` or `crypto.randomUUID()` (including optional-chain forms `crypto?.getRandomValues(buf)` and optional-call forms `crypto.getRandomValues?.(buf)`). These calls generate cryptographic randomness at runtime but are invisible to botscript's capability model: `uses { random }` covers `random.*` stdlib calls (`random.next()`, `random.int()`), not the `crypto` global. A fn that calls these methods has an undeclared randomness dependency — tests cannot control the output and callers cannot observe the dependency from the fn header. Detection: `crypto` ident not preceded by `.`/`?.`, followed by `.` or `?.`, followed by `getRandomValues` or `randomUUID`, followed by `(` or `?.(`. Member calls (`obj.crypto.getRandomValues(...)`), non-randomness members (e.g. `crypto.subtle.digest(...)`), bare references, and `fn`/`function` declarations named `crypto` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Use `random.next()` or `random.int(min, max)` from the `random` stdlib with `uses { random }` when general randomness is sufficient. If cryptographic randomness or UUIDs are genuinely required, wrap in `unsafe "uses crypto for " { crypto.getRandomValues(buf) }`. | | SYN022 | (0.7+, warning) A fn body accesses `process.argv`, `process.cwd()`, `process.platform`, `process.arch`, `process.pid`, `process.ppid`, `process.version`, `process.versions`, `process.hrtime()`, `process.uptime()`, `process.memoryUsage()`, `process.cpuUsage()`, or `process.resourceUsage()`. These read ambient Node.js process state at runtime — OS identity, process tree, memory, CPU — invisible to botscript's capability model. Unlike `process.env` (SYN005) and `process.exit` (SYN006), which cover configuration and termination respectively, SYN022 targets the remaining ambient introspection surface. Detection: `process` ident not preceded by `.`/`?.`, followed by `.` or `?.`, member in the ambient-state set, optionally followed by `(` or `?.(`. Bare `process.*` references without a trailing `(` still fire. `obj.process.*` member calls, `unsafe {}` blocks, and `unsafe "reason" fn` bodies are excluded. `process.env` and `process.exit` are handled by SYN005/SYN006 and excluded here. | Pass the required ambient value as an explicit fn parameter so the dependency is visible in the call signature and tests can inject a mock. If direct `process.*` access is required at a bootstrap entry point, wrap in `unsafe "reads process state for " { process.argv }`. | | SYN023 | (0.7+, warning) A fn body accesses a high-concern `navigator.*` member: `geolocation`, `clipboard`, `mediaDevices`, `serviceWorker`, `permissions`, `onLine`, `userAgent`, `language`, `languages`, `platform`, `hardwareConcurrency`, `deviceMemory`, `connection`, or `wakeLock`. These expose ambient browser capability state — location, clipboard, media devices, background service workers, network connectivity, browser identity, and hardware specs — invisible to botscript's capability model. Detection: `navigator` ident not preceded by `.`/`?.`, followed by `.` or `?.`, member in the high-concern set. `obj.navigator.*` member calls, `fn`/`function`/`function*` declarations named `navigator`, members not in the listed set, `unsafe {}` blocks, and `unsafe "reason" fn` bodies are excluded. | Pass the required value as an explicit fn parameter so callers can see the dependency and tests can inject a mock. If direct `navigator.*` access is required, wrap in `unsafe "accesses navigator. for " { navigator. }`. | +| SYN027 | (0.7+, warning) A fn body calls `postMessage` in any of these forms: bare `postMessage(...)`, `postMessage?.(...)`; or via an ambient global: `window.postMessage(...)`, `globalThis.postMessage(...)`, `self.postMessage(...)` (including optional-chain variants like `window?.postMessage(...)`). All of these send structured data cross-origin, a capability-model bypass invisible to `uses {}`, `reads {}`, or `writes {}` declarations. In bot/agent contexts, `postMessage` (in any form) is a known prompt-injection exfiltration vector. Detection: `postMessage` ident either not preceded by `.`/`?.` (bare call) or preceded by `.`/`?.` from an ambient global (`window`, `globalThis`, `self`) that is itself not a member access. Excluded: member calls on non-ambient handles (`worker.postMessage`, `iframe.contentWindow.postMessage`); `obj.window.postMessage(...)` where `window` is not a bare global; `fn`/`function`/`function*` declarations named `postMessage`; object method shorthands. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. Relation: SYN007 (fetch/HTTP), SYN014 (BroadcastChannel/same-origin broadcast). | Pass the messaging function as an explicit parameter so the cross-origin dependency is declared in the fn header. If direct cross-origin messaging is required, wrap in `unsafe "posts cross-origin message for " { postMessage(data, origin) }`. | | 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. | | INT004 | (0.7+) A fn declares `intent: "idempotent"` but its body directly references `random` or `time` without declaring them. Under-declaration variant of INT003 — fires when INT003 does not. | Remove the non-idempotent call from the body, or declare the capability and remove the idempotent intent. | diff --git a/README.md b/README.md index 9a05c460..51234a7f 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`, `SYN004`, `SYN005`, `SYN006`, `SYN007`, `SYN008`, `SYN010`, `SYN011`, `SYN012`, `SYN013`, `SYN014`, `SYN016`, `SYN017`, `SYN018`, `SYN019`, `SYN022`, `SYN023`, `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`, `SYN007`, `SYN008`, `SYN010`, `SYN011`, `SYN012`, `SYN013`, `SYN014`, `SYN016`, `SYN017`, `SYN018`, `SYN019`, `SYN022`, `SYN023`, `SYN027`, `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/examples/react-app/src/main.tsx b/examples/react-app/src/main.tsx index 65d10309..5d311e55 100644 --- a/examples/react-app/src/main.tsx +++ b/examples/react-app/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App.bs"; +import "./messaging.bs"; // SYN027 example: ensure the file is compiled const root = document.getElementById("root"); if (!root) throw new Error("missing #root"); diff --git a/examples/react-app/src/messaging.bs b/examples/react-app/src/messaging.bs new file mode 100644 index 00000000..2b51c64a --- /dev/null +++ b/examples/react-app/src/messaging.bs @@ -0,0 +1,29 @@ +?bs 0.7 + +// SYN027 example: bare postMessage is invisible to the capability model. +// Pass the messaging function as a parameter so the dependency is declared. + +// This would fire SYN027 — caller cannot see the cross-origin dependency: +// fn notifyParent(userId: string) -> void { +// postMessage({ type: "user-ready", id: userId }, "https://parent.example.com") +// } + +// Fix: pass the post function as a parameter — dependency is now visible +fn notifyParent( + post: (msg: object, origin: string) -> void, + userId: string, +) -> void { + post({ type: "user-ready", id: userId }, "https://parent.example.com") +} + +// Unsafe escape hatch when direct cross-origin messaging is required +fn notifyParentDirect(userId: string) -> void { + unsafe "posts user-ready event to parent frame" { + postMessage({ type: "user-ready", id: userId }, "https://parent.example.com") + } +} + +// Member calls on a declared handle are fine (NOT flagged by SYN027): +fn sendToWorker(worker: Worker, data: object) -> void { + worker.postMessage(data) +} diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index 870026a4..388a5f93 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -1085,6 +1085,54 @@ const E: Record = { " return userAgent\n" + "}", }, + SYN027: { + code: "SYN027", + title: "postMessage() cross-origin call (bare or via window/globalThis/self) bypasses the capability model", + rule: + "a bare `postMessage(...)` or ambient-global spelling (`window.postMessage(...)`, " + + "`globalThis.postMessage(...)`, `self.postMessage(...)`) in a fn body sends structured " + + "data to another browsing context — a cross-origin communication dependency invisible to " + + "botscript's capability model; no `uses {}`, `writes {}`, or `reads {}` declaration covers it; " + + "in bot/agent contexts, `postMessage` is a known prompt-injection exfiltration vector: " + + "injected content can call `postMessage` to leak data to an attacker-controlled origin; " + + "excluded: member calls on non-ambient handles (`worker.postMessage`, `iframe.contentWindow.postMessage`) — " + + "these operate on an already-declared handle; `fn`/`function`/`function*` declarations " + + "named `postMessage` and method shorthands are also excluded; " + + "suppressed inside `unsafe {}` blocks and `unsafe \"reason\" fn` bodies", + idiom: + "pass the messaging function as an explicit parameter so the cross-origin dependency is " + + "declared in the fn header; if direct bare `postMessage` is required, wrap in unsafe", + rewrite: + "// before — bare postMessage is invisible to the capability model\n" + + "fn notifyParent(userId: string) -> void {\n" + + " postMessage({ type: \"user-ready\", id: userId }, \"https://parent.example.com\") // SYN027\n" + + "}\n\n" + + "// after — pass the post function as a parameter so the dependency is declared\n" + + "fn notifyParent(\n" + + " post: (msg: object, origin: string) -> void,\n" + + " userId: string\n" + + ") -> void {\n" + + " post({ type: \"user-ready\", id: userId }, \"https://parent.example.com\")\n" + + "}", + example: + "// SYN027: bare postMessage bypasses the capability model\n" + + "fn notifyParent(userId: string) -> void {\n" + + " postMessage({ type: \"user-ready\", id: userId }, \"https://parent.example.com\")\n" + + "}\n\n" + + "// fix A: pass the messaging function as a parameter\n" + + "fn notifyParent(\n" + + " post: (msg: object, origin: string) -> void,\n" + + " userId: string,\n" + + ") -> void {\n" + + " post({ type: \"user-ready\", id: userId }, \"https://parent.example.com\")\n" + + "}\n\n" + + "// fix B: wrap in unsafe when direct cross-origin messaging is required\n" + + "fn notifyParent(userId: string) -> void {\n" + + " unsafe \"posts user-ready event to parent frame\" {\n" + + " postMessage({ type: \"user-ready\", id: userId }, \"https://parent.example.com\")\n" + + " }\n" + + "}", + }, DEP001: { code: "DEP001", title: "fn transitively reads a resource category not declared in its header", diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index d3d1ad24..2e9a43f3 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -182,6 +182,21 @@ * named `navigator`, member accesses not in the high-concern list above. * `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. * + * SYN027 A bare `postMessage(data, origin)`, `window.postMessage(...)`, + * `globalThis.postMessage(...)`, or `self.postMessage(...)` call was detected in a + * fn body (?bs 0.7+). These are all ambient-global spellings that send structured + * data to another browsing context — a cross-origin communication dependency + * invisible to botscript's capability model: no `uses { net }`, `writes {}`, or + * `reads {}` declaration covers it. + * In bot/agent contexts this is a known prompt-injection exfiltration vector: + * injected content can call `postMessage` to leak data to an attacker-controlled + * origin without any declared capability. + * Excluded: member calls on non-ambient handles (`worker.postMessage`, + * `iframe.contentWindow.postMessage`) — these operate on an already-declared handle. + * `fn`/`function`/`function*` declarations named `postMessage` and method shorthands + * are also excluded. + * `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. + * * All checks share a single token scan per fn body. The outer loop runs once, * skipping nested fn bodies once. Per-token dispatch is a switch on tok.text * after a kind==="ident" guard. @@ -229,6 +244,7 @@ const SYN023_NAVIGATOR_MEMBERS = new Set([ "onLine", "userAgent", "language", "languages", "platform", "hardwareConcurrency", "deviceMemory", "connection", "wakeLock", ]); +const AMBIENT_GLOBALS_27 = new Set(["window", "globalThis", "self"]); export function passSynCheck(src: string, version: VersionInfo): SynCheckResult { if (!atLeast(version.resolved, "0.7")) return { code: src, warnings: [] }; @@ -255,6 +271,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const syn019 = getErrorCode("SYN019")!; const syn022 = getErrorCode("SYN022")!; const syn023 = getErrorCode("SYN023")!; + const syn027 = getErrorCode("SYN027")!; // Collect char-offset ranges where all SYN checks are suppressed: // 1. `unsafe "reason" { ... }` expression blocks — explicit acknowledgment. @@ -1683,6 +1700,86 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult break; } + // ── SYN027: bare postMessage() / window.postMessage() / globalThis.postMessage() ── + case "postMessage": { + const prevIdx27 = prevSignificant(tokens, i - 1); + const prev27 = tokens[prevIdx27]; + + // Exclude non-ambient member calls: `worker.postMessage(...)`, `iframe.contentWindow.postMessage(...)`. + // But include ambient-global spellings: `window.postMessage(...)`, `globalThis.postMessage(...)`, + // `self.postMessage(...)` — these are still ambient globals, just written explicitly. + let ambientReceiver27: string | null = null; + let ambientDot27: string | null = null; + if (prev27 && ((prev27.kind === "punct" && prev27.text === ".") || prev27.kind === "questionDot")) { + const prevPrevIdx27 = prevSignificant(tokens, prevIdx27 - 1); + const prevPrev27 = tokens[prevPrevIdx27]; + // Must be a bare ambient global (not itself a member access like `obj.window.postMessage`) + const isAmbient = + prevPrev27?.kind === "ident" && + AMBIENT_GLOBALS_27.has(prevPrev27.text) && + (() => { + const pppi = prevSignificant(tokens, prevPrevIdx27 - 1); + const ppp = tokens[pppi]; + return !(ppp && ((ppp.kind === "punct" && ppp.text === ".") || ppp.kind === "questionDot")); + })(); + if (!isAmbient) continue; + // Fall through: `window.postMessage(...)` — treat as ambient, track receiver for message + ambientReceiver27 = prevPrev27!.text; + ambientDot27 = prev27.kind === "questionDot" ? "?." : "."; + } + + // Exclude: fn/function/function* declarations named postMessage + if (prev27 && prev27.kind === "ident" && prev27.text === "function") continue; + if (prev27 && prev27.kind === "keyword" && prev27.text === "fn") continue; + if (isFunctionStarDecl(tokens, prevIdx27)) continue; + + // Must be followed by `(` or `?.(` — confirming this is a call. + let afterIdx27 = nextSignificant(tokens, i + 1); + let afterTok27 = tokens[afterIdx27]; + let isOpt27 = false; + if (afterTok27 && afterTok27.kind === "questionDot") { + isOpt27 = true; + afterIdx27 = nextSignificant(tokens, afterIdx27 + 1); + afterTok27 = tokens[afterIdx27]; + } + if (!afterTok27 || !(afterTok27.kind === "open" && afterTok27.text === "(")) continue; + + // Exclude object method shorthands: `{ postMessage(data) { ... } }` + // Do NOT exclude on `:` — that would create false negatives for ternary consequents + // like `cond ? postMessage(data, origin) : other` where `:` is the ternary separator. + if (afterTok27.matchedAt !== undefined) { + const afterParen27 = tokens[nextSignificant(tokens, afterTok27.matchedAt + 1)]; + if (afterParen27 && afterParen27.kind === "open" && afterParen27.text === "{") continue; + } + + if (isInsideRange(tok.start, unsafeRanges)) continue; + + const callSep27 = isOpt27 ? "?." : ""; + // Build call site description: `window.postMessage()` or `postMessage()` + const callDesc27 = ambientReceiver27 + ? `${ambientReceiver27}${ambientDot27}postMessage${callSep27}()` + : `postMessage${callSep27}()`; + const loc27 = locationOf(src, tok.start); + warnings.push({ + code: "SYN027", + severity: "warning", + file: null, + line: loc27.line, + column: loc27.column, + start: tok.start, + end: afterTok27.start + 1, + message: + `fn '${decl.name}' calls ${callDesc27} — ` + + `${callDesc27} sends data to another browsing context, invisible to the capability model; ` + + `pass the messaging function as a parameter so the dependency is declared in the fn header, ` + + `or wrap in unsafe "posts message for " { ${callDesc27.replace(/\(\)$/, "(data, origin)")} }`, + rule: syn027.rule, + idiom: syn027.idiom, + rewrite: syn027.rewrite, + }); + break; + } + // ── SYN010: setTimeout / setInterval / queueMicrotask ──────────────── default: { if (!TIMER_GLOBALS.has(tok.text)) continue; diff --git a/packages/compiler/tests/error-codes.test.ts b/packages/compiler/tests/error-codes.test.ts index 5d061660..7d3b710d 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", "SYN004", "SYN005", "SYN006", "SYN007", "SYN008", "SYN010", "SYN011", "SYN012", "SYN013", "SYN014", "SYN016", "SYN017", "SYN018", "SYN019", "SYN022", "SYN023", + "SYN001", "SYN002", "SYN003", "SYN004", "SYN005", "SYN006", "SYN007", "SYN008", "SYN010", "SYN011", "SYN012", "SYN013", "SYN014", "SYN016", "SYN017", "SYN018", "SYN019", "SYN022", "SYN023", "SYN027", "THR001", "THR002", "THR003", "THR004", "UNS001", "UNS002", "UNS003", "UNS004", "UNS005", "VER001", "VER002", "VER003", diff --git a/packages/compiler/tests/syn027-check.test.ts b/packages/compiler/tests/syn027-check.test.ts new file mode 100644 index 00000000..9ed3640e --- /dev/null +++ b/packages/compiler/tests/syn027-check.test.ts @@ -0,0 +1,229 @@ +/** + * Tests for SYN027: bare postMessage() detection (?bs 0.7+). + * + * SYN027 is a non-blocking warning — transform must not throw. + */ + +import { describe, it, expect } from "vitest"; +import { transform } from "../src/index.js"; + +function compile(src: string) { + return transform(src, {}); +} + +describe("SYN027: bare postMessage() cross-origin messaging detection", () => { + it("fires on bare postMessage(data, origin) inside a fn body", () => { + const src = + "?bs 0.7\n" + + "fn notifyParent(userId: string) -> void {\n" + + " postMessage({ type: \"user-ready\", id: userId }, \"https://parent.example.com\")\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN027")).toBe(true); + }); + + it("fires on optional-call form postMessage?.(data, origin)", () => { + const src = + "?bs 0.7\n" + + "fn notifyParent(userId: string) -> void {\n" + + " postMessage?.({ type: \"ready\" }, \"https://parent.example.com\")\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN027")).toBe(true); + }); + + it("warning message includes fn name and postMessage", () => { + const src = + "?bs 0.7\n" + + "fn notifyParent(userId: string) -> void {\n" + + " postMessage({ type: \"ready\" }, \"https://parent.example.com\")\n" + + "}\n"; + const result = compile(src); + const warn = result.warnings.find((w) => w.code === "SYN027"); + expect(warn).toBeDefined(); + expect(warn!.message).toContain("notifyParent"); + expect(warn!.message).toContain("postMessage"); + }); + + it("does NOT fire below ?bs 0.7", () => { + const src = + "?bs 0.6\n" + + "fn notifyParent(userId: string) -> void {\n" + + " postMessage({ type: \"ready\" }, \"https://parent.example.com\")\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN027")).toBe(false); + }); + + it("does NOT fire inside an unsafe block", () => { + const src = + "?bs 0.7\n" + + "fn notifyParent(userId: string) -> void {\n" + + " unsafe \"posts user-ready event to parent frame\" { postMessage({ id: userId }, \"https://parent.example.com\") }\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN027")).toBe(false); + }); + + it("does NOT fire inside an unsafe fn body", () => { + const src = + "?bs 0.7\n" + + "unsafe \"cross-origin messaging wrapper\" fn notifyParent(userId: string) -> void {\n" + + " postMessage({ type: \"ready\", id: userId }, \"https://parent.example.com\")\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN027")).toBe(false); + }); + + it("does NOT fire on member call worker.postMessage(data)", () => { + const src = + "?bs 0.7\n" + + "fn sendToWorker(worker: any, data: object) -> void {\n" + + " worker.postMessage(data)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN027")).toBe(false); + }); + + it("does NOT fire on member call iframe.contentWindow.postMessage(data, origin)", () => { + const src = + "?bs 0.7\n" + + "fn sendToFrame(iframe: any, data: object) -> void {\n" + + " iframe.contentWindow.postMessage(data, \"https://child.example.com\")\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN027")).toBe(false); + }); + + it("does NOT fire on optional member call obj?.postMessage(data)", () => { + const src = + "?bs 0.7\n" + + "fn sendIfExists(target: any, data: object) -> void {\n" + + " target?.postMessage(data)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN027")).toBe(false); + }); + + it("does NOT fire on fn declaration named postMessage", () => { + const src = + "?bs 0.7\n" + + "fn postMessage(data: object, origin: string) -> void {\n" + + " console.log(data)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN027")).toBe(false); + }); + + it("does NOT fire on function keyword declaration named postMessage", () => { + const src = + "?bs 0.7\n" + + "fn wrapper() -> void {\n" + + " function postMessage(data: object, origin: string) { }\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN027")).toBe(false); + }); + + it("does NOT fire on object method shorthand { postMessage(data) { ... } }", () => { + const src = + "?bs 0.7\n" + + "fn makeHandler() -> any {\n" + + " return { postMessage(data: object) { return data } }\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN027")).toBe(false); + }); + + it("does NOT fire on bare postMessage reference without call parens", () => { + const src = + "?bs 0.7\n" + + "fn getRef() -> any {\n" + + " return postMessage\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN027")).toBe(false); + }); + + it("fires on window.postMessage(data, origin) — ambient global spelling", () => { + const src = + "?bs 0.7\n" + + "fn notifyParent(userId: string) -> void {\n" + + " window.postMessage({ type: \"ready\", id: userId }, \"https://parent.example.com\")\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN027")).toBe(true); + }); + + it("window.postMessage message names the receiver (not 'bare postMessage')", () => { + const src = + "?bs 0.7\n" + + "fn notifyParent(userId: string) -> void {\n" + + " window.postMessage({ type: \"ready\", id: userId }, \"https://parent.example.com\")\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN027"); + expect(w).toBeDefined(); + expect(w!.message).toContain("window.postMessage"); + }); + + it("fires on globalThis.postMessage(data, origin) — ambient global spelling", () => { + const src = + "?bs 0.7\n" + + "fn notifyParent(userId: string) -> void {\n" + + " globalThis.postMessage({ type: \"ready\", id: userId }, \"https://parent.example.com\")\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN027")).toBe(true); + }); + + it("fires on self.postMessage(data, origin) — ambient global spelling", () => { + const src = + "?bs 0.7\n" + + "fn notifyParent(userId: string) -> void {\n" + + " self.postMessage({ type: \"ready\", id: userId }, \"https://parent.example.com\")\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN027")).toBe(true); + }); + + it("does NOT fire on obj.window.postMessage(data, origin) — non-ambient receiver", () => { + const src = + "?bs 0.7\n" + + "fn sendTo(ctx: any, data: object) -> void {\n" + + " ctx.window.postMessage(data, \"https://example.com\")\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN027")).toBe(false); + }); + + it("fires on window?.postMessage(data, origin) — optional-chain ambient spelling", () => { + const src = + "?bs 0.7\n" + + "fn notifyParent(userId: string) -> void {\n" + + " window?.postMessage({ type: \"ready\", id: userId }, \"https://parent.example.com\")\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN027")).toBe(true); + }); + + it("does NOT fire on function* postMessage(...) generator declaration", () => { + const src = + "?bs 0.7\n" + + "fn wrapper() -> any {\n" + + " function* postMessage(data: object) { yield data }\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN027")).toBe(false); + }); + + it("fires on postMessage() in ternary consequent — not suppressed by ternary colon", () => { + const src = + "?bs 0.7\n" + + "fn maybeNotify(send: boolean, data: object) -> void {\n" + + " send ? postMessage(data, \"https://parent.example.com\") : null\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN027")).toBe(true); + }); +}); diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index 53475fb5..a32b3473 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -1386,6 +1386,70 @@ export const EXPLANATIONS: Readonly> = { "}\n", }, }, + SYN027: { + code: "SYN027", + title: "postMessage() cross-origin call (bare or via window/globalThis/self) bypasses the capability model", + body: + "SYN027 fires when a fn body contains a cross-origin `postMessage` call in `?bs 0.7+`. " + + "Detected forms: bare `postMessage(...)`, `postMessage?.(...)`; and the explicit ambient-global " + + "spellings `window.postMessage(...)`, `globalThis.postMessage(...)`, `self.postMessage(...)` " + + "(including optional-chain variants like `window?.postMessage(...)`).\n\n" + + "**Why it matters:** All of these are the same cross-origin communication surface — they " + + "send structured data to another browsing context (a different tab, iframe, or worker) at " + + "a specified origin. This dependency is completely invisible to botscript's capability " + + "model: no `uses { net }`, `writes {}`, or `reads {}` declaration covers it. A fn that " + + "calls any of these forms can exfiltrate data or trigger remote behavior in other browsing " + + "contexts, with nothing visible in the fn header.\n\n" + + "**Security angle:** In bot/agent contexts, `postMessage` (in any form) is a known " + + "prompt-injection exfiltration vector. Injected content in a page can call it to leak " + + "data to an attacker-controlled origin. Making this surface visible in code review is " + + "the point of the check.\n\n" + + "**Relation to SYN007/SYN014:** SYN007 catches `fetch()` (HTTP net bypass); " + + "SYN014 catches `BroadcastChannel` (same-origin broadcast bypass); SYN027 covers " + + "the missing entry — bare cross-origin messaging.\n\n" + + "**Excluded forms:**\n" + + "- Member calls on non-ambient handles (`worker.postMessage(data)`, " + + " `iframe.contentWindow.postMessage(data, origin)`) — these operate on an already-declared handle.\n" + + "- `obj.window.postMessage(...)` where `window` is not itself a bare global.\n" + + "- `fn postMessage(...)` / `function postMessage(...)` / `function* postMessage(...)` declarations.\n" + + "- Object method shorthands `{ postMessage(data) { ... } }`.\n" + + "- Inside `unsafe { }` blocks and `unsafe \"reason\" fn` bodies.\n\n" + + "**Fix (preferred — pass as a parameter):**\n\n" + + "```\n" + + "// SYN027 — before (any of these forms fires)\n" + + "fn notifyParent(userId: string) -> void {\n" + + " postMessage({ type: \"user-ready\", id: userId }, \"https://parent.example.com\")\n" + + " // also detected: window.postMessage(...), globalThis.postMessage(...), self.postMessage(...)\n" + + "}\n\n" + + "// fix — pass the messaging function as a parameter\n" + + "fn notifyParent(\n" + + " post: (msg: object, origin: string) -> void,\n" + + " userId: string,\n" + + ") -> void {\n" + + " post({ type: \"user-ready\", id: userId }, \"https://parent.example.com\")\n" + + "}\n" + + "```\n\n" + + "**Fix (escape hatch):** if direct cross-origin messaging is required:\n" + + "`unsafe \"posts user-ready event to parent frame\" { postMessage(data, origin) }`\n\n" + + "SYN027 fires at `?bs 0.7+` as a non-blocking warning. " + + "Calls inside `unsafe { }` blocks or `unsafe \"reason\" fn` bodies are suppressed.", + example: { + fails: + "?bs 0.7\n" + + "fn notifyParent(userId: string) -> void {\n" + + " window.postMessage({ type: \"user-ready\", id: userId }, \"https://parent.example.com\")\n" + + "}\n", + passes: + "?bs 0.7\n" + + "// pass the messaging function as a parameter\n" + + "fn notifyParent(\n" + + " post: (msg: object, origin: string) -> void,\n" + + " userId: string,\n" + + ") -> void {\n" + + " post({ type: \"user-ready\", id: userId }, \"https://parent.example.com\")\n" + + "}\n", + }, + }, DEP001: { code: "DEP001", title: "fn transitively reads a resource category not declared in its header", diff --git a/packages/mcp/tests/server.test.ts b/packages/mcp/tests/server.test.ts index eb13233a..f0287688 100644 --- a/packages/mcp/tests/server.test.ts +++ b/packages/mcp/tests/server.test.ts @@ -91,6 +91,7 @@ describe("botscript-mcp explanations", () => { "SYN019", "SYN022", "SYN023", + "SYN027", "THR001", "THR002", "THR003",