Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <reason>" { 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 <reason>" { 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.<member> for <reason>" { navigator.<member> }`. |
| 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 <reason>" { 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. |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions examples/react-app/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
29 changes: 29 additions & 0 deletions examples/react-app/src/messaging.bs
Original file line number Diff line number Diff line change
@@ -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.

Comment on lines +1 to +5
// 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)
}
48 changes: 48 additions & 0 deletions packages/compiler/src/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1085,6 +1085,54 @@ const E: Record<string, ErrorCodeEntry> = {
" return userAgent\n" +
"}",
},
SYN027: {
code: "SYN027",
title: "postMessage() cross-origin call (bare or via window/globalThis/self) bypasses the capability model",
Comment on lines +1088 to +1090
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",
Expand Down
97 changes: 97 additions & 0 deletions packages/compiler/src/passes/syn-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +185 to +188
* 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.
Expand Down Expand Up @@ -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: [] };
Expand All @@ -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.
Expand Down Expand Up @@ -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);
Comment on lines +1713 to +1714
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;
}
Comment on lines +1747 to +1753

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 <reason>" { ${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;
Expand Down
2 changes: 1 addition & 1 deletion packages/compiler/tests/error-codes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading