From 7b11880e93abf90db068349f70b235593038fc95 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 18 Jun 2026 23:39:08 -0300 Subject: [PATCH 1/5] =?UTF-8?q?feat(compiler):=20SYN027=20=E2=80=94=20warn?= =?UTF-8?q?=20on=20bare=20postMessage()=20that=20bypasses=20the=20capabili?= =?UTF-8?q?ty=20model=20(=3Fbs=200.7+,=20closes=20#180)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare postMessage(data, origin) sends data cross-origin to another browsing context, a network-equivalent dependency invisible to botscript's capability model. No uses {}, reads {}, or writes {} declaration covers it. In bot/agent contexts this is a known prompt-injection exfiltration vector: injected content can call bare postMessage to leak data to an attacker-controlled origin. Detection: postMessage not preceded by ./?., followed by ( or ?.(. Excluded: member calls (worker.postMessage, iframe.contentWindow.postMessage), fn/function/function* declarations, object method shorthands, and unsafe {}/unsafe fn bodies. Relation to existing checks: SYN007 (fetch/HTTP), SYN014 (BroadcastChannel/same-origin broadcast), SYN027 (cross-origin messaging) — completes the net-bypass family. Wired through error-codes, MCP explain, tests (13 cases), AGENTS.md, README.md, and examples/react-app/src/messaging.bs. Co-Authored-By: Botkowski --- AGENTS.md | 1 + README.md | 2 +- examples/react-app/src/messaging.bs | 29 ++++ packages/compiler/src/error-codes.ts | 48 ++++++ packages/compiler/src/passes/syn-check.ts | 74 ++++++++++ packages/compiler/tests/error-codes.test.ts | 2 +- packages/compiler/tests/syn027-check.test.ts | 147 +++++++++++++++++++ packages/mcp/src/explanations.ts | 61 ++++++++ packages/mcp/tests/server.test.ts | 1 + 9 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 examples/react-app/src/messaging.bs create mode 100644 packages/compiler/tests/syn027-check.test.ts diff --git a/AGENTS.md b/AGENTS.md index 01484c34..48785dca 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 bare `postMessage(data, origin)`, `postMessage?.(data, origin)` — any call not preceded by `.`/`?.`. Bare `postMessage` sends structured data cross-origin to another browsing context, a cross-origin communication dependency invisible to botscript's capability model (no `uses { net }`, `reads {}`, or `writes {}` covers it). In bot/agent contexts `postMessage` is a known prompt-injection exfiltration vector. Detection: `postMessage` ident not preceded by `.`/`?.`, followed by `(` or `?.(`. Excluded: member calls (`worker.postMessage`, `iframe.contentWindow.postMessage`) — these operate on an already-declared handle; `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), SYN027 (cross-origin messaging). | 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/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..6b918683 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -1049,6 +1049,54 @@ const E: Record = { " return argv[2]\n" + "}", }, + SYN027: { + code: "SYN027", + title: "bare postMessage() call sends data cross-origin, bypassing the capability model", + rule: + "a bare `postMessage(data, origin)` or `postMessage?.(data, origin)` call (not preceded " + + "by `.` or `?.`) in a fn body sends structured data to another browsing context at a " + + "different origin — 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 bare `postMessage` to leak data to an attacker-controlled origin; " + + "excluded: member calls (`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" + + "}", + }, SYN023: { code: "SYN023", title: "navigator.* ambient browser capability access bypasses the capability model", diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index d3d1ad24..9a6811ec 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -182,6 +182,19 @@ * 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)` or `postMessage?.(data, origin)` call was + * detected in a fn body (?bs 0.7+). Bare `postMessage` (not preceded by `.`/`?.`) + * sends structured data to another browsing context at a different origin — 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 bare `postMessage` to leak data to an attacker-controlled + * origin without any declared capability. + * Excluded: member calls (`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. @@ -255,6 +268,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 +1697,66 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult break; } + // ── SYN027: bare postMessage() call (cross-origin messaging bypass) ── + case "postMessage": { + const prevIdx27 = prevSignificant(tokens, i - 1); + const prev27 = tokens[prevIdx27]; + + // Exclude: member calls — `worker.postMessage(...)`, `window.postMessage(...)` + if (prev27 && ((prev27.kind === "punct" && prev27.text === ".") || prev27.kind === "questionDot")) + continue; + + // 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 method shorthands and TS method signatures: + // `{ postMessage(data) { ... } }` / `{ postMessage(data): void; }` + if (afterTok27.matchedAt !== undefined) { + const afterParen27 = tokens[nextSignificant(tokens, afterTok27.matchedAt + 1)]; + if ( + afterParen27 && + ((afterParen27.kind === "open" && afterParen27.text === "{") || + (afterParen27.kind === "punct" && afterParen27.text === ":")) + ) continue; + } + + if (isInsideRange(tok.start, unsafeRanges)) continue; + + const callSep27 = isOpt27 ? "?." : ""; + 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 postMessage${callSep27}() — ` + + `bare postMessage sends data cross-origin, 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 cross-origin message for " { postMessage${callSep27}(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..5d8cd56a --- /dev/null +++ b/packages/compiler/tests/syn027-check.test.ts @@ -0,0 +1,147 @@ +/** + * 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); + }); +}); diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index 53475fb5..0b4458c1 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -1338,6 +1338,67 @@ export const EXPLANATIONS: Readonly> = { "}\n", }, }, + SYN027: { + code: "SYN027", + title: "bare postMessage() sends data cross-origin, bypassing the capability model", + body: + "SYN027 fires when a fn body contains a bare `postMessage(data, origin)` or " + + "`postMessage?.(data, origin)` call in `?bs 0.7+` — that is, an identifier call not " + + "preceded by `.` or `?.`.\n\n" + + "**Why it matters:** Bare `postMessage` sends structured data to another browsing " + + "context (a different tab, iframe, or worker) at a specified origin. This is a " + + "cross-origin communication dependency, but it is completely invisible to botscript's " + + "capability model: no `uses { net }`, `writes {}`, or `reads {}` declaration covers it. " + + "A fn that calls bare `postMessage` 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, bare `postMessage` is a known " + + "prompt-injection exfiltration vector. Injected content in a page can call " + + "`postMessage` 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 (`worker.postMessage(data)`, `iframe.contentWindow.postMessage(data, origin)`) " + + " — these operate on an already-declared handle, analogous to `http.post`.\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\n" + + "fn notifyParent(userId: string) -> void {\n" + + " postMessage({ type: \"user-ready\", id: userId }, \"https://parent.example.com\")\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" + + " 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", + }, + }, SYN023: { code: "SYN023", title: "navigator.* ambient browser capability access bypasses the capability model", 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", From c46fa306efb1184af9fa8c580e4af229c769f57b Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 19 Jun 2026 03:29:52 -0300 Subject: [PATCH 2/5] fix(SYN027): extend detection to window/globalThis/self.postMessage; import messaging example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SYN027 now fires on `window.postMessage(...)`, `globalThis.postMessage(...)`, and `self.postMessage(...)` — these are ambient-global spellings of the same cross-origin surface as bare `postMessage`. Only excluded when the receiver is itself a member (e.g. `obj.window.postMessage` — non-ambient handle). - Add 5 new test cases covering the three ambient spellings, the non-ambient exclusion, and the optional-chain form `window?.postMessage(...)`. - Import `./messaging.bs` as a side-effect in `examples/react-app/src/main.tsx` so the example is guaranteed to be compiled by vite (not just present in the src directory). - Update error-codes rule/title and MCP explain body to document all detected forms. Addresses Copilot review suggestions on PR #182. --- examples/react-app/src/main.tsx | 1 + packages/compiler/src/error-codes.ts | 14 +++--- packages/compiler/src/passes/syn-check.ts | 44 ++++++++++++----- packages/compiler/tests/syn027-check.test.ts | 50 ++++++++++++++++++++ packages/mcp/src/explanations.ts | 39 ++++++++------- 5 files changed, 110 insertions(+), 38 deletions(-) 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/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index 6b918683..e038e082 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -1051,15 +1051,15 @@ const E: Record = { }, SYN027: { code: "SYN027", - title: "bare postMessage() call sends data cross-origin, bypassing the capability model", + title: "postMessage() cross-origin call (bare or via window/globalThis/self) bypasses the capability model", rule: - "a bare `postMessage(data, origin)` or `postMessage?.(data, origin)` call (not preceded " + - "by `.` or `?.`) in a fn body sends structured data to another browsing context at a " + - "different origin — a cross-origin communication dependency invisible to botscript's " + - "capability model; no `uses {}`, `writes {}`, or `reads {}` declaration covers it; " + + "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 bare `postMessage` to leak data to an attacker-controlled origin; " + - "excluded: member calls (`worker.postMessage`, `iframe.contentWindow.postMessage`) — " + + "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", diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 9a6811ec..a5c312b7 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -182,17 +182,19 @@ * 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)` or `postMessage?.(data, origin)` call was - * detected in a fn body (?bs 0.7+). Bare `postMessage` (not preceded by `.`/`?.`) - * sends structured data to another browsing context at a different origin — a - * cross-origin communication dependency invisible to botscript's capability model: - * no `uses { net }`, `writes {}`, or `reads {}` declaration covers it. + * 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 bare `postMessage` to leak data to an attacker-controlled + * injected content can call `postMessage` to leak data to an attacker-controlled * origin without any declared capability. - * Excluded: member calls (`worker.postMessage`, `iframe.contentWindow.postMessage`) — - * these operate on an already-declared handle. `fn`/`function`/`function*` declarations - * named `postMessage` and method shorthands are also excluded. + * 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, @@ -1697,14 +1699,30 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult break; } - // ── SYN027: bare postMessage() call (cross-origin messaging bypass) ── + // ── SYN027: bare postMessage() / window.postMessage() / globalThis.postMessage() ── case "postMessage": { const prevIdx27 = prevSignificant(tokens, i - 1); const prev27 = tokens[prevIdx27]; - // Exclude: member calls — `worker.postMessage(...)`, `window.postMessage(...)` - if (prev27 && ((prev27.kind === "punct" && prev27.text === ".") || prev27.kind === "questionDot")) - continue; + // 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. + if (prev27 && ((prev27.kind === "punct" && prev27.text === ".") || prev27.kind === "questionDot")) { + const AMBIENT_GLOBALS_27 = new Set(["window", "globalThis", "self"]); + 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, warn below + } // Exclude: fn/function/function* declarations named postMessage if (prev27 && prev27.kind === "ident" && prev27.text === "function") continue; diff --git a/packages/compiler/tests/syn027-check.test.ts b/packages/compiler/tests/syn027-check.test.ts index 5d8cd56a..cc714ced 100644 --- a/packages/compiler/tests/syn027-check.test.ts +++ b/packages/compiler/tests/syn027-check.test.ts @@ -144,4 +144,54 @@ describe("SYN027: bare postMessage() cross-origin messaging detection", () => { 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("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); + }); }); diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index 0b4458c1..726685fd 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -1340,35 +1340,38 @@ export const EXPLANATIONS: Readonly> = { }, SYN027: { code: "SYN027", - title: "bare postMessage() sends data cross-origin, bypassing the capability model", + title: "postMessage() cross-origin call (bare or via window/globalThis/self) bypasses the capability model", body: - "SYN027 fires when a fn body contains a bare `postMessage(data, origin)` or " + - "`postMessage?.(data, origin)` call in `?bs 0.7+` — that is, an identifier call not " + - "preceded by `.` or `?.`.\n\n" + - "**Why it matters:** Bare `postMessage` sends structured data to another browsing " + - "context (a different tab, iframe, or worker) at a specified origin. This is a " + - "cross-origin communication dependency, but it is completely invisible to botscript's " + - "capability model: no `uses { net }`, `writes {}`, or `reads {}` declaration covers it. " + - "A fn that calls bare `postMessage` 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, bare `postMessage` is a known " + - "prompt-injection exfiltration vector. Injected content in a page can call " + - "`postMessage` to leak data to an attacker-controlled origin. Making this surface " + - "visible in code review is the point of the check.\n\n" + + "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 (`worker.postMessage(data)`, `iframe.contentWindow.postMessage(data, origin)`) " + - " — these operate on an already-declared handle, analogous to `http.post`.\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\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" + @@ -1386,7 +1389,7 @@ export const EXPLANATIONS: Readonly> = { fails: "?bs 0.7\n" + "fn notifyParent(userId: string) -> void {\n" + - " postMessage({ type: \"user-ready\", id: userId }, \"https://parent.example.com\")\n" + + " window.postMessage({ type: \"user-ready\", id: userId }, \"https://parent.example.com\")\n" + "}\n", passes: "?bs 0.7\n" + From 28062232841c2beef1015cdfe7da35e51b0124fb Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 19 Jun 2026 11:33:31 -0300 Subject: [PATCH 3/5] =?UTF-8?q?fix(compiler):=20address=20Copilot=20SYN027?= =?UTF-8?q?=20review=20=E2=80=94=20accurate=20message=20for=20ambient-glob?= =?UTF-8?q?al=20forms,=20numeric=20ordering=20in=20registry=20and=20MCP,?= =?UTF-8?q?=20updated=20AGENTS.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Warning message now uses the actual call form: `window.postMessage()` or `globalThis.postMessage()` instead of always saying "bare postMessage" for ambient-global spellings; track receiver name at detection time - Move SYN027 entry in error-codes.ts and explanations.ts after SYN023 to restore numeric ordering within the SYN family - Update AGENTS.md SYN027 row to document both bare and ambient-global detection forms (window/globalThis/self.postMessage), excluded forms, and suppression rules - Add test: window.postMessage message must contain "window.postMessage", not "bare postMessage" - The messaging.bs example was already wired into main.tsx via side-effect import; no change needed Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 2 +- packages/compiler/src/error-codes.ts | 72 +++++++-------- packages/compiler/src/passes/syn-check.ts | 14 ++- packages/compiler/tests/syn027-check.test.ts | 12 +++ packages/mcp/src/explanations.ts | 96 ++++++++++---------- 5 files changed, 108 insertions(+), 88 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 48785dca..d089fc5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -214,7 +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 bare `postMessage(data, origin)`, `postMessage?.(data, origin)` — any call not preceded by `.`/`?.`. Bare `postMessage` sends structured data cross-origin to another browsing context, a cross-origin communication dependency invisible to botscript's capability model (no `uses { net }`, `reads {}`, or `writes {}` covers it). In bot/agent contexts `postMessage` is a known prompt-injection exfiltration vector. Detection: `postMessage` ident not preceded by `.`/`?.`, followed by `(` or `?.(`. Excluded: member calls (`worker.postMessage`, `iframe.contentWindow.postMessage`) — these operate on an already-declared handle; `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), SYN027 (cross-origin messaging). | 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) }`. | +| 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/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index e038e082..388a5f93 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -1049,6 +1049,42 @@ const E: Record = { " return argv[2]\n" + "}", }, + SYN023: { + code: "SYN023", + title: "navigator.* ambient browser capability access bypasses the capability model", + rule: + "`navigator.geolocation`, `navigator.clipboard`, `navigator.mediaDevices`, " + + "`navigator.serviceWorker`, `navigator.permissions`, `navigator.onLine`, " + + "`navigator.userAgent`, `navigator.language`, `navigator.languages`, " + + "`navigator.platform`, `navigator.hardwareConcurrency`, `navigator.deviceMemory`, " + + "`navigator.connection`, and `navigator.wakeLock` read ambient browser capability " + + "state at runtime but are invisible to botscript's capability model: no `uses {}`, " + + "`reads {}`, or `writes {}` declaration covers them. A fn that reads these values " + + "has an undeclared browser-environment dependency — callers cannot see it in the " + + "header, and tests cannot inject a controlled value.", + idiom: + "pass the required value as an explicit parameter so callers and tests can control it (preferred); " + + "if the ambient access is intentional, wrap in " + + "`unsafe \"accesses navigator. for \" { navigator. }`", + rewrite: + "// before — ambient navigator state invisible to the capability model\n" + + "fn isConnected() -> boolean {\n" + + " return navigator.onLine // SYN023\n" + + "}\n\n" + + "// after — online status passed as a parameter; tests can control it\n" + + "fn isConnected(onLine: boolean) -> boolean {\n" + + " return onLine\n" + + "}", + example: + "// SYN023: navigator.userAgent bypasses the capability model\n" + + "fn getBrowser() -> string {\n" + + " return navigator.userAgent // SYN023\n" + + "}\n\n" + + "// fix: accept userAgent as a parameter\n" + + "fn getBrowser(userAgent: string) -> string {\n" + + " return userAgent\n" + + "}", + }, SYN027: { code: "SYN027", title: "postMessage() cross-origin call (bare or via window/globalThis/self) bypasses the capability model", @@ -1097,42 +1133,6 @@ const E: Record = { " }\n" + "}", }, - SYN023: { - code: "SYN023", - title: "navigator.* ambient browser capability access bypasses the capability model", - rule: - "`navigator.geolocation`, `navigator.clipboard`, `navigator.mediaDevices`, " + - "`navigator.serviceWorker`, `navigator.permissions`, `navigator.onLine`, " + - "`navigator.userAgent`, `navigator.language`, `navigator.languages`, " + - "`navigator.platform`, `navigator.hardwareConcurrency`, `navigator.deviceMemory`, " + - "`navigator.connection`, and `navigator.wakeLock` read ambient browser capability " + - "state at runtime but are invisible to botscript's capability model: no `uses {}`, " + - "`reads {}`, or `writes {}` declaration covers them. A fn that reads these values " + - "has an undeclared browser-environment dependency — callers cannot see it in the " + - "header, and tests cannot inject a controlled value.", - idiom: - "pass the required value as an explicit parameter so callers and tests can control it (preferred); " + - "if the ambient access is intentional, wrap in " + - "`unsafe \"accesses navigator. for \" { navigator. }`", - rewrite: - "// before — ambient navigator state invisible to the capability model\n" + - "fn isConnected() -> boolean {\n" + - " return navigator.onLine // SYN023\n" + - "}\n\n" + - "// after — online status passed as a parameter; tests can control it\n" + - "fn isConnected(onLine: boolean) -> boolean {\n" + - " return onLine\n" + - "}", - example: - "// SYN023: navigator.userAgent bypasses the capability model\n" + - "fn getBrowser() -> string {\n" + - " return navigator.userAgent // SYN023\n" + - "}\n\n" + - "// fix: accept userAgent as a parameter\n" + - "fn getBrowser(userAgent: string) -> string {\n" + - " return userAgent\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 a5c312b7..6cb25e75 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -1707,6 +1707,8 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult // 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 AMBIENT_GLOBALS_27 = new Set(["window", "globalThis", "self"]); const prevPrevIdx27 = prevSignificant(tokens, prevIdx27 - 1); @@ -1721,7 +1723,9 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult return !(ppp && ((ppp.kind === "punct" && ppp.text === ".") || ppp.kind === "questionDot")); })(); if (!isAmbient) continue; - // Fall through: `window.postMessage(...)` — treat as ambient, warn below + // 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 @@ -1754,6 +1758,10 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult 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", @@ -1764,8 +1772,8 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult start: tok.start, end: afterTok27.start + 1, message: - `fn '${decl.name}' calls postMessage${callSep27}() — ` + - `bare postMessage sends data cross-origin, invisible to the capability model; ` + + `fn '${decl.name}' calls ${callDesc27} — ` + + `${callDesc27} sends data cross-origin, 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 cross-origin message for " { postMessage${callSep27}(data, origin) }`, rule: syn027.rule, diff --git a/packages/compiler/tests/syn027-check.test.ts b/packages/compiler/tests/syn027-check.test.ts index cc714ced..813724af 100644 --- a/packages/compiler/tests/syn027-check.test.ts +++ b/packages/compiler/tests/syn027-check.test.ts @@ -155,6 +155,18 @@ describe("SYN027: bare postMessage() cross-origin messaging detection", () => { 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" + diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index 726685fd..a32b3473 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -1338,6 +1338,54 @@ export const EXPLANATIONS: Readonly> = { "}\n", }, }, + SYN023: { + code: "SYN023", + title: "navigator.* ambient browser capability access bypasses the capability model", + body: + "SYN023 fires when a fn body accesses a high-concern `navigator.*` member in `?bs 0.7+`. " + + "The covered members are: `geolocation`, `clipboard`, `mediaDevices`, `serviceWorker`, " + + "`permissions`, `onLine`, `userAgent`, `language`, `languages`, `platform`, " + + "`hardwareConcurrency`, `deviceMemory`, `connection`, and `wakeLock`.\n\n" + + "**Why it matters:** These properties expose ambient browser capability state — the " + + "device's physical location, the clipboard contents, media input devices, background " + + "service workers, network connectivity, browser identity, hardware specs, and display " + + "wake locks. None of these are covered by botscript's capability model: no `uses {}`, " + + "`reads {}`, or `writes {}` declaration captures a `navigator.*` access. A fn that " + + "reads them has an undeclared browser-environment dependency — callers cannot see it " + + "in the header, and tests cannot inject a controlled value without monkey-patching the " + + "global `navigator` object.\n\n" + + "**Detected forms:** `navigator.` or `navigator?.` where `` " + + "is in the high-concern set above. Member calls on local bindings (`obj.navigator.*`) " + + "and `fn navigator(...)` / `function navigator(...)` / `function* navigator(...)` " + + "declarations are excluded. Members not in the listed set do not fire.\n\n" + + "**Fix (preferred — pass as a parameter):**\n\n" + + "```\n" + + "// SYN023 — before\n" + + "fn isConnected() -> boolean {\n" + + " return navigator.onLine\n" + + "}\n\n" + + "// fix — onLine passed as a parameter; tests can control it\n" + + "fn isConnected(onLine: boolean) -> boolean {\n" + + " return onLine\n" + + "}\n" + + "```\n\n" + + "**Fix (escape hatch):** if the ambient access is intentional:\n" + + "`unsafe \"accesses navigator.geolocation for location services\" { navigator.geolocation }`\n\n" + + "SYN023 fires at `?bs 0.7+` as a non-blocking warning. " + + "Accesses inside `unsafe { }` blocks or `unsafe \"reason\" fn` bodies are suppressed.", + example: { + fails: + "?bs 0.7\n" + + "fn getBrowser() -> string {\n" + + " return navigator.userAgent\n" + + "}\n", + passes: + "?bs 0.7\n" + + "fn getBrowser(userAgent: string) -> string {\n" + + " return userAgent\n" + + "}\n", + }, + }, SYN027: { code: "SYN027", title: "postMessage() cross-origin call (bare or via window/globalThis/self) bypasses the capability model", @@ -1402,54 +1450,6 @@ export const EXPLANATIONS: Readonly> = { "}\n", }, }, - SYN023: { - code: "SYN023", - title: "navigator.* ambient browser capability access bypasses the capability model", - body: - "SYN023 fires when a fn body accesses a high-concern `navigator.*` member in `?bs 0.7+`. " + - "The covered members are: `geolocation`, `clipboard`, `mediaDevices`, `serviceWorker`, " + - "`permissions`, `onLine`, `userAgent`, `language`, `languages`, `platform`, " + - "`hardwareConcurrency`, `deviceMemory`, `connection`, and `wakeLock`.\n\n" + - "**Why it matters:** These properties expose ambient browser capability state — the " + - "device's physical location, the clipboard contents, media input devices, background " + - "service workers, network connectivity, browser identity, hardware specs, and display " + - "wake locks. None of these are covered by botscript's capability model: no `uses {}`, " + - "`reads {}`, or `writes {}` declaration captures a `navigator.*` access. A fn that " + - "reads them has an undeclared browser-environment dependency — callers cannot see it " + - "in the header, and tests cannot inject a controlled value without monkey-patching the " + - "global `navigator` object.\n\n" + - "**Detected forms:** `navigator.` or `navigator?.` where `` " + - "is in the high-concern set above. Member calls on local bindings (`obj.navigator.*`) " + - "and `fn navigator(...)` / `function navigator(...)` / `function* navigator(...)` " + - "declarations are excluded. Members not in the listed set do not fire.\n\n" + - "**Fix (preferred — pass as a parameter):**\n\n" + - "```\n" + - "// SYN023 — before\n" + - "fn isConnected() -> boolean {\n" + - " return navigator.onLine\n" + - "}\n\n" + - "// fix — onLine passed as a parameter; tests can control it\n" + - "fn isConnected(onLine: boolean) -> boolean {\n" + - " return onLine\n" + - "}\n" + - "```\n\n" + - "**Fix (escape hatch):** if the ambient access is intentional:\n" + - "`unsafe \"accesses navigator.geolocation for location services\" { navigator.geolocation }`\n\n" + - "SYN023 fires at `?bs 0.7+` as a non-blocking warning. " + - "Accesses inside `unsafe { }` blocks or `unsafe \"reason\" fn` bodies are suppressed.", - example: { - fails: - "?bs 0.7\n" + - "fn getBrowser() -> string {\n" + - " return navigator.userAgent\n" + - "}\n", - passes: - "?bs 0.7\n" + - "fn getBrowser(userAgent: string) -> string {\n" + - " return userAgent\n" + - "}\n", - }, - }, DEP001: { code: "DEP001", title: "fn transitively reads a resource category not declared in its header", From 759df715e4879f5f9256e39f113cca3ae16cab55 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 19 Jun 2026 15:30:43 -0300 Subject: [PATCH 4/5] =?UTF-8?q?fix(compiler):=20address=20Copilot=20SYN027?= =?UTF-8?q?=20review=20=E2=80=94=20hoist=20AMBIENT=5FGLOBALS=5F27,=20fix?= =?UTF-8?q?=20ternary=20false=20negative,=20add=20generator=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hoist AMBIENT_GLOBALS_27 to module level (was re-allocated per token) - Remove ':' from method-shorthand exclusion: it caused false negatives for postMessage() in ternary consequents (cond ? postMessage(...) : x) - Add function* generator declaration exclusion test - Add ternary-consequent regression test Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/syn-check.ts | 13 +++++-------- packages/compiler/tests/syn027-check.test.ts | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 6cb25e75..29386a24 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -244,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: [] }; @@ -1710,7 +1711,6 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult let ambientReceiver27: string | null = null; let ambientDot27: string | null = null; if (prev27 && ((prev27.kind === "punct" && prev27.text === ".") || prev27.kind === "questionDot")) { - const AMBIENT_GLOBALS_27 = new Set(["window", "globalThis", "self"]); 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`) @@ -1744,15 +1744,12 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult } if (!afterTok27 || !(afterTok27.kind === "open" && afterTok27.text === "(")) continue; - // Exclude method shorthands and TS method signatures: - // `{ postMessage(data) { ... } }` / `{ postMessage(data): void; }` + // 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 === "{") || - (afterParen27.kind === "punct" && afterParen27.text === ":")) - ) continue; + if (afterParen27 && afterParen27.kind === "open" && afterParen27.text === "{") continue; } if (isInsideRange(tok.start, unsafeRanges)) continue; diff --git a/packages/compiler/tests/syn027-check.test.ts b/packages/compiler/tests/syn027-check.test.ts index 813724af..9ed3640e 100644 --- a/packages/compiler/tests/syn027-check.test.ts +++ b/packages/compiler/tests/syn027-check.test.ts @@ -206,4 +206,24 @@ describe("SYN027: bare postMessage() cross-origin messaging detection", () => { 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); + }); }); From 75ee99a7d41fd69c1d9c71cf385e91c5cca6ece1 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 19 Jun 2026 19:38:14 -0300 Subject: [PATCH 5/5] =?UTF-8?q?fix(syn027):=20address=20Copilot=20review?= =?UTF-8?q?=20=E2=80=94=20accurate=20message=20wording=20and=20unsafe-wrap?= =?UTF-8?q?=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change "sends data cross-origin" → "sends data to another browsing context" because postMessage can target same-origin contexts (same page, same worker pool). The original wording was technically inaccurate. - Fix the unsafe-wrap suggestion to mirror the actual detected call form: window.postMessage(data, origin) was suggested as postMessage(data, origin), which confused readers when the flagged call was the explicit ambient spelling. Use callDesc27.replace(/\(\)$/, "(data, origin)") so the suggestion matches the form that was actually detected. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/syn-check.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 29386a24..2e9a43f3 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -1770,9 +1770,9 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult end: afterTok27.start + 1, message: `fn '${decl.name}' calls ${callDesc27} — ` + - `${callDesc27} sends data cross-origin, invisible to the capability model; ` + + `${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 cross-origin message for " { postMessage${callSep27}(data, origin) }`, + `or wrap in unsafe "posts message for " { ${callDesc27.replace(/\(\)$/, "(data, origin)")} }`, rule: syn027.rule, idiom: syn027.idiom, rewrite: syn027.rewrite,