From f04050231a1c5d387219b6fe45a2b6f27139eda0 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 10 Jun 2026 15:44:29 -0300 Subject: [PATCH 1/9] =?UTF-8?q?feat(compiler):=20SYN008=20=E2=80=94=20warn?= =?UTF-8?q?=20on=20WebSocket()=20calls=20that=20bypass=20the=20net=20capab?= =?UTF-8?q?ility=20model=20(=3Fbs=200.7+)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `new WebSocket(url)`, `WebSocket(url)`, and TypeScript instantiation forms like `new WebSocket(url)` open persistent bidirectional connections that are invisible to CAP001 (which only checks `http.*` member calls). A fn that constructs a WebSocket has an undeclared net dependency — no `uses { net }` reflects it. - Same bypass class as fetch (SYN007) and console.* (SYN003): real network effects, invisible to the declared capability surface. - Unlike SYN007, there is no stdlib WebSocket wrapper, so the idiomatic fix is to wrap in `unsafe "reason" { new WebSocket(url) }` rather than replacing with an http.* call. - Suppressed inside `unsafe {}` blocks and `unsafe "reason" fn` bodies. - Detection: `WebSocket` not preceded by `.`/`?.`, followed by `(`, `?.(`, or `(`. Nested generics (e.g. `WebSocket>`) handled correctly via `>> / >>>` depth scan. Files: packages/compiler/src/error-codes.ts — SYN008 registry entry packages/compiler/src/passes/syn-check.ts — detection pass packages/compiler/tests/syn008-check.test.ts — 11 tests packages/compiler/tests/error-codes.test.ts — add SYN008 to allowlist packages/mcp/src/explanations.ts — long-form MCP explanation packages/mcp/tests/server.test.ts — add SYN008 to KNOWN_CODES AGENTS.md / README.md — document SYN008 Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 1 + README.md | 2 +- packages/compiler/src/error-codes.ts | 35 ++++++ packages/compiler/src/passes/syn-check.ts | 89 +++++++++++++- packages/compiler/tests/error-codes.test.ts | 2 +- packages/compiler/tests/syn008-check.test.ts | 116 +++++++++++++++++++ packages/mcp/src/explanations.ts | 42 +++++++ packages/mcp/tests/server.test.ts | 1 + 8 files changed, 285 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/tests/syn008-check.test.ts diff --git a/AGENTS.md b/AGENTS.md index 6bb47fdb..d16e3541 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -200,6 +200,7 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope. | SYN003 | (0.7+, warning) A fn body contains a `console.*` call (console.log, console.error, etc.). Direct console output bypasses the `stdout`/`stderr` capability model — the compiler cannot enforce or surface the output declaration for callers. | Replace `console.log(...)` with `stdout.write(...)` and add `uses { stdout }` to the fn header; replace `console.error(...)` with `stderr.write(...)` and add `uses { stderr }`. | | SYN005 | (0.7+, warning) A fn body accesses `process.env`. `process.env` is a global deployment-environment namespace — access is invisible to callers, there is no capability or resource declaration that covers it, and the fn has an undeclared dependency on deployment configuration. Detection: `process` not preceded by `.`/`?.`, followed by `.`/`?.` then `env`. `obj.process.env` (member access on a local), `unsafe {}` blocks, and `unsafe "reason" fn` bodies are excluded. | Pass config and secrets as explicit fn parameters so the dependency is visible in the call signature; if env access is required at the load site, wrap in `unsafe "reads deployment env" { }`. | | SYN006 | (0.7+, warning) A fn body calls `process.exit()`, `process?.exit()`, or `process.exit?.()`. All forms terminate the entire host process — not just the fn, not just the bot. They produce no return value, bypass `Result` propagation, `throws {}`, `match`, and any caller recovery path. No capability declaration covers them. Detection: `process` not preceded by `.`/`?.`, followed by `.`/`?.` then `exit` then `(` or `?.(`. `obj.process.exit(...)`, `process.exit` without `(`, and `process.exitCode` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Return `err(...)` and let the caller decide whether to terminate. If `process.exit` is genuinely required at a bootstrap entry point, wrap in `unsafe "exits on invalid config" { process.exit(1) }`. | +| SYN008 | (0.7+, warning) A fn body constructs a `WebSocket` via `new WebSocket(url)`, `WebSocket(url)`, or TypeScript instantiation forms like `new WebSocket(url)`. All forms open persistent bidirectional connections at runtime but are invisible to CAP001 (which checks `http.*` member calls). A fn that constructs a WebSocket has an undeclared `net` dependency. Detection: `WebSocket` not preceded by `.`/`?.`, followed by `(`, `?.(`, or `(`. `obj.WebSocket(...)` and bare `WebSocket` references are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Wrap the constructor in `unsafe "wraps WebSocket directly" { new WebSocket(url) }` to make the escape hatch visible in the diff; note that `uses { net }` alone will trigger CAP002 without a stdlib `http.*` call; write a thin `$require("net")`-checked wrapper for full capability tracking. | | 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 1bf8d55c..cf476bd2 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`, `DEP002`, `EFF002`–`EFF004`, `FMT001`, `INT001`–`INT005`, `MAT001`–`MAT004`, `RES001`, `RES002`, `SYN001`, `SYN002`, `SYN003`, `SYN005`, `SYN006`, `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`, `DEP002`, `EFF002`–`EFF004`, `FMT001`, `INT001`–`INT005`, `MAT001`–`MAT004`, `RES001`, `RES002`, `SYN001`, `SYN002`, `SYN003`, `SYN005`, `SYN006`, `SYN008`, `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 aa8aa867..48ece019 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -611,6 +611,41 @@ const E: Record = { " return ok(undefined)\n" + "}", }, + SYN008: { + code: "SYN008", + title: "new WebSocket() / WebSocket() call bypasses the net capability model", + rule: + "`new WebSocket(url)`, `WebSocket(url)`, and TypeScript instantiation forms like " + + "`new WebSocket(url)` open persistent bidirectional connections at runtime but are " + + "invisible to botscript's capability model: CAP001 checks for `http.*` member calls, " + + "not the `WebSocket` global. A fn that constructs a WebSocket has an undeclared network " + + "dependency — no `uses { net }` reflects it, no audit tool can see it, and the connection " + + "outlives the fn's return value.", + idiom: + "wrap the `WebSocket` constructor in `unsafe \"wraps WebSocket directly\" { new WebSocket(url) }` " + + "to make the escape hatch visible in the diff; for full capability tracking, write a thin " + + "wrapper fn that calls `$require(\"net\")` before constructing the socket", + rewrite: + "// before — WebSocket is invisible to the capability model\n" + + "fn openFeed(url: string) -> WebSocket {\n" + + " return new WebSocket(url) // SYN008\n" + + "}\n\n" + + "// after — escape hatch justified in the diff\n" + + "fn openFeed(url: string) -> WebSocket {\n" + + ' return unsafe "wraps WebSocket for streaming feed" { new WebSocket(url) }\n' + + "}", + example: + "// SYN008: WebSocket bypasses the net capability model\n" + + "fn subscribe(url: string) -> void {\n" + + " const ws = new WebSocket(url) // SYN008\n" + + " ws.onmessage = (e) => handle(e.data)\n" + + "}\n\n" + + "// fix: wrap in unsafe with a justification\n" + + "fn subscribe(url: string) -> void {\n" + + ' const ws = unsafe "wraps WebSocket for live updates" { new WebSocket(url) }\n' + + " ws.onmessage = (e) => handle(e.data)\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 eb1c94ed..a5c9ab0d 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -23,6 +23,13 @@ * depends on runtime deployment values that callers cannot see, * audit, or mock in tests. The idiomatic fix is to pass config * and secrets as explicit fn parameters. + * + * SYN008 A `new WebSocket(...)` or `WebSocket(...)` call was detected in a + * fn body (?bs 0.7+). WebSocket opens a persistent bidirectional + * connection that is invisible to CAP001 (which checks `http.*` + * member calls). A fn that constructs a WebSocket has an undeclared + * `net` dependency. Wrap in `unsafe "reason" { }` and declare + * `uses { net }`, or write a thin `$require("net")`-checked wrapper. */ import type { Diagnostic } from "../diagnostics.js"; @@ -55,8 +62,9 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const syn003 = getErrorCode("SYN003")!; const syn005 = getErrorCode("SYN005")!; const syn006 = getErrorCode("SYN006")!; + const syn008 = getErrorCode("SYN008")!; - // Collect char-offset ranges where SYN002/SYN003/SYN005/SYN006 are suppressed: + // Collect char-offset ranges where SYN002/SYN003/SYN005/SYN006/SYN008 are suppressed: // 1. `unsafe "reason" { ... }` expression blocks — explicit acknowledgment. // 2. `unsafe "reason" fn` bodies — the entire body is exempt, including any // non-unsafe nested fns declared inside it (matching uns-check's pattern). @@ -396,6 +404,85 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult rewrite: syn006.rewrite, }); } + + // SYN008: WebSocket constructor/call detection. + // Fires when a fn body contains `new WebSocket(...)`, `WebSocket(...)`, or + // TypeScript instantiation form `new WebSocket(...)` / `WebSocket(...)`. + // All forms open a persistent bidirectional network connection at runtime + // but are invisible to CAP001 (which checks `http.*` member calls only). + // Suppressed inside `unsafe { }` blocks and `unsafe fn` bodies. + nextInner = 0; + const open8: typeof inner = []; + for (let i = bodyStart; i < decl.tokenEnd; i++) { + while (open8.length > 0 && open8[open8.length - 1]!.tokenEnd <= i) open8.pop(); + while (nextInner < inner.length && inner[nextInner]!.tokenStart <= i) { + open8.push(inner[nextInner]!); + nextInner++; + } + if (open8.length > 0) continue; + + const tok8 = tokens[i]; + if (!tok8 || tok8.kind !== "ident" || tok8.text !== "WebSocket") continue; + + // Exclude: `obj.WebSocket(...)` or `obj?.WebSocket(...)` — member call on a local. + const prevIdx8 = prevSignificant(tokens, i - 1); + const prev8 = tokens[prevIdx8]; + if (prev8 && ((prev8.kind === "punct" && prev8.text === ".") || prev8.kind === "questionDot")) + continue; + + // Must be followed by `(`, `?.(`, or `(` — confirming this is a + // constructor or direct call, not a bare `WebSocket` reference. + let afterWsIdx = nextSignificant(tokens, i + 1); + const afterWs = tokens[afterWsIdx]; + if (!afterWs) continue; + + // TypeScript instantiation form: `WebSocket(...)` or `new WebSocket(...)` + if (afterWs.kind === "operator" && afterWs.text === "<") { + let anglDepth = 1; + afterWsIdx++; + while (afterWsIdx < decl.tokenEnd && anglDepth > 0) { + const at = tokens[afterWsIdx]; + if (!at) { afterWsIdx++; continue; } + if (at.kind === "operator" && at.text === "<") { anglDepth++; } + else if (at.kind === "operator" && at.text === ">") { anglDepth--; } + else if (at.kind === "operator" && (at.text === ">>" || at.text === ">>>")) { + anglDepth -= at.text.length; + } + afterWsIdx++; + } + afterWsIdx = nextSignificant(tokens, afterWsIdx); + const afterAngle8 = tokens[afterWsIdx]; + if (!afterAngle8 || !(afterAngle8.kind === "open" && afterAngle8.text === "(")) continue; + } else if (afterWs.kind === "questionDot") { + // `WebSocket?.(...)` — optional call + const afterQD8 = nextSignificant(tokens, afterWsIdx + 1); + const afterQDTok8 = tokens[afterQD8]; + if (!afterQDTok8 || !(afterQDTok8.kind === "open" && afterQDTok8.text === "(")) continue; + } else if (!(afterWs.kind === "open" && afterWs.text === "(")) { + continue; + } + + // Suppression check: unsafe block or unsafe fn body + if (isInsideRange(tok8.start, unsafeRanges)) continue; + + const loc8 = locationOf(src, tok8.start); + warnings.push({ + code: "SYN008", + severity: "warning", + file: null, + line: loc8.line, + column: loc8.column, + start: tok8.start, + end: tok8.end, + message: + `fn '${decl.name}' constructs a WebSocket — WebSocket bypasses the net capability model; ` + + `CAP001 cannot see it; declare uses { net } and wrap in ` + + `unsafe "wraps WebSocket directly" { new WebSocket(url) }`, + rule: syn008.rule, + idiom: syn008.idiom, + rewrite: syn008.rewrite, + }); + } } return { code: src, warnings }; diff --git a/packages/compiler/tests/error-codes.test.ts b/packages/compiler/tests/error-codes.test.ts index 0eb75b0a..b319ce98 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", "SYN005", "SYN006", + "SYN001", "SYN002", "SYN003", "SYN005", "SYN006", "SYN008", "THR001", "THR002", "THR003", "THR004", "UNS001", "UNS002", "UNS003", "UNS004", "UNS005", "VER001", "VER002", "VER003", diff --git a/packages/compiler/tests/syn008-check.test.ts b/packages/compiler/tests/syn008-check.test.ts new file mode 100644 index 00000000..41d230d0 --- /dev/null +++ b/packages/compiler/tests/syn008-check.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from "vitest"; +import { transform } from "../src/index.js"; + +describe("SYN008 — WebSocket bypasses the net capability model", () => { + it("fires on new WebSocket(url)", () => { + const src = + "?bs 0.7\n" + + "fn subscribe(url: string) -> void {\n" + + " const ws = new WebSocket(url)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(true); + }); + + it("fires on bare WebSocket(url) call without new", () => { + const src = + "?bs 0.7\n" + + "fn subscribe(url: string) -> void {\n" + + " const ws = WebSocket(url)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(true); + }); + + it("fires on TypeScript instantiation form new WebSocket(url)", () => { + const src = + "?bs 0.7\n" + + "fn subscribe(url: string) -> void {\n" + + " const ws = new WebSocket(url)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(true); + }); + + it("fires on nested generic form new WebSocket>(url)", () => { + const src = + "?bs 0.7\n" + + "fn subscribe(url: string) -> void {\n" + + " const ws = new WebSocket>(url)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(true); + }); + + it("fires on WebSocket?.(url) optional call", () => { + const src = + "?bs 0.7\n" + + "fn subscribe(url: string) -> void {\n" + + " const ws = WebSocket?.(url)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(true); + }); + + it("does not fire below ?bs 0.7", () => { + const src = + "?bs 0.6\n" + + "fn subscribe(url: string) -> void {\n" + + " const ws = new WebSocket(url)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(false); + }); + + it("does not fire when WebSocket is inside an unsafe block", () => { + const src = + "?bs 0.7\n" + + "fn subscribe(url: string) -> void {\n" + + ' const ws = unsafe "wraps WebSocket for live feed" { new WebSocket(url) }\n' + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(false); + }); + + it("does not fire when WebSocket is inside an unsafe fn body", () => { + const src = + "?bs 0.7\n" + + 'unsafe "wraps WebSocket" fn subscribe(url: string) -> void {\n' + + " const ws = new WebSocket(url)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(false); + }); + + it("does not fire on obj.WebSocket(url) — member call on a local", () => { + const src = + "?bs 0.7\n" + + "fn subscribe(url: string) -> void {\n" + + " const ws = factory.WebSocket(url)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(false); + }); + + it("does not fire on bare WebSocket reference without call", () => { + const src = + "?bs 0.7\n" + + "fn isSupported() -> boolean {\n" + + " return typeof WebSocket !== 'undefined'\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(false); + }); + + it("warning carries correct code and severity", () => { + const src = + "?bs 0.7\n" + + "fn subscribe(url: string) -> void {\n" + + " const ws = new WebSocket(url)\n" + + "}\n"; + const result = transform(src); + const w = result.warnings.find((w) => w.code === "SYN008"); + expect(w).toBeDefined(); + expect(w!.severity).toBe("warning"); + }); +}); diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index fe445593..ee6fb132 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -812,6 +812,48 @@ export const EXPLANATIONS: Readonly> = { "}\n", }, }, + SYN008: { + code: "SYN008", + title: "new WebSocket() / WebSocket() call bypasses the net capability model", + body: + "Botscript's capability model maps network access to `uses { net }` via the `http` stdlib " + + "namespace. CAP001 enforces this by checking for `http.*` member calls (e.g. `http.get()`, " + + "`http.post()`). But the `WebSocket` global opens a persistent bidirectional connection without " + + "any `http.*` call — CAP001 never fires, and `uses { net }` is absent from the fn header.\n\n" + + "The impact is more severe than `fetch` (SYN007):\n" + + "- A WebSocket connection persists beyond the fn's return, making the connection lifetime " + + "invisible to the caller\n" + + "- A fn that constructs a WebSocket has a live, long-lived network dependency that callers " + + "cannot see, audit, or mock in tests\n" + + "- Audit tools and orchestrators that read `uses { net }` declarations will miss the dependency\n" + + "- The connection can receive data and trigger callbacks after the constructing fn has returned\n\n" + + "This is the same bypass class as `fetch` (SYN007) and `console.*` (SYN003): real network " + + "effects that sidestep the declared capability surface.\n\n" + + "**Fix:** wrap the `WebSocket` constructor in `unsafe \"wraps WebSocket directly\" { new WebSocket(url) }` " + + "to make the escape hatch visible in the diff. Note that simply adding `uses { net }` to the fn header " + + "without an `http.*` call will trigger CAP002 (over-declared capability) — the stdlib currently has no " + + "WebSocket wrapper. For full capability tracking, write a thin wrapper fn that calls `$require(\"net\")` " + + "before constructing the socket.\n\n" + + "SYN008 fires at `?bs 0.7+` as a non-blocking warning. Detection is token-based: " + + "`WebSocket` not preceded by `.`/`?.` followed by `(`, `?.(`, or `(`. " + + "Both `new WebSocket(url)` and `WebSocket(url)` (without `new`) are detected. " + + "`obj.WebSocket(...)` (method call on a local object) is excluded. " + + "Calls inside `unsafe { }` blocks or `unsafe \"reason\" fn` bodies are suppressed.", + example: { + fails: + "?bs 0.7\n" + + "fn subscribe(url: string) -> void {\n" + + " const ws = new WebSocket(url)\n" + + " ws.onmessage = (e) => handle(e.data)\n" + + "}\n", + passes: + "?bs 0.7\n" + + "fn subscribe(url: string) -> void {\n" + + ' const ws = unsafe "wraps WebSocket for live updates" { new WebSocket(url) }\n' + + " ws.onmessage = (e) => handle(e.data)\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 57e87d71..793f8369 100644 --- a/packages/mcp/tests/server.test.ts +++ b/packages/mcp/tests/server.test.ts @@ -75,6 +75,7 @@ describe("botscript-mcp explanations", () => { "SYN003", "SYN005", "SYN006", + "SYN008", "THR001", "THR002", "THR003", From 2f0d1400b3f45b784ef117ed0211736c08ec7a99 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 10 Jun 2026 23:25:57 -0300 Subject: [PATCH 2/9] =?UTF-8?q?fix(compiler):=20SYN008=20=E2=80=94=20don't?= =?UTF-8?q?=20recommend=20`uses=20{=20net=20}`=20alone;=20CAP002=20fires?= =?UTF-8?q?=20without=20http.*=20calls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review: the doc comment and warning message both suggested `uses { net }` as part of the fix, but `uses { net }` without an underlying `http.*` call triggers CAP002 (over-declared capability). Fix both to recommend `unsafe { ... }` with an optional `$require("net")`-checked wrapper instead. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/syn-check.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index a5c9ab0d..c3982f6b 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -28,8 +28,8 @@ * fn body (?bs 0.7+). WebSocket opens a persistent bidirectional * connection that is invisible to CAP001 (which checks `http.*` * member calls). A fn that constructs a WebSocket has an undeclared - * `net` dependency. Wrap in `unsafe "reason" { }` and declare - * `uses { net }`, or write a thin `$require("net")`-checked wrapper. + * `net` dependency. Wrap in `unsafe "reason" { }`; for full + * capability tracking, write a thin `$require("net")`-checked wrapper. */ import type { Diagnostic } from "../diagnostics.js"; @@ -476,8 +476,8 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult end: tok8.end, message: `fn '${decl.name}' constructs a WebSocket — WebSocket bypasses the net capability model; ` + - `CAP001 cannot see it; declare uses { net } and wrap in ` + - `unsafe "wraps WebSocket directly" { new WebSocket(url) }`, + `CAP001 cannot see it; wrap in ` + + `unsafe "wraps WebSocket directly" { new WebSocket(url) }, or write a $require("net")-checked wrapper`, rule: syn008.rule, idiom: syn008.idiom, rewrite: syn008.rewrite, From 5dfbf1578c019afa092cec17804a779672a94d1c Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 11 Jun 2026 03:33:26 -0300 Subject: [PATCH 3/9] fix(compiler): address Copilot review feedback on SYN008 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SYN006 to header comment (was missing between SYN005 and SYN008) - Remove forward references to SYN007 in explanations.ts — SYN007 is not in this branch; reword to describe the fetch bypass class without the code --- packages/compiler/src/passes/syn-check.ts | 7 +++++++ packages/mcp/src/explanations.ts | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index c3982f6b..031a0042 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -24,6 +24,13 @@ * audit, or mock in tests. The idiomatic fix is to pass config * and secrets as explicit fn parameters. * + * SYN006 A `process.exit()` call was detected in a fn body (?bs 0.7+). + * `process.exit()` terminates the entire host process — not just the + * fn, not just the bot. It produces no return value and bypasses + * Result propagation, throws {}, match, and any caller recovery + * path. The idiomatic fix is `return err(...)` so the caller can + * decide whether to terminate. + * * SYN008 A `new WebSocket(...)` or `WebSocket(...)` call was detected in a * fn body (?bs 0.7+). WebSocket opens a persistent bidirectional * connection that is invisible to CAP001 (which checks `http.*` diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index ee6fb132..4aab6d00 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -820,14 +820,14 @@ export const EXPLANATIONS: Readonly> = { "namespace. CAP001 enforces this by checking for `http.*` member calls (e.g. `http.get()`, " + "`http.post()`). But the `WebSocket` global opens a persistent bidirectional connection without " + "any `http.*` call — CAP001 never fires, and `uses { net }` is absent from the fn header.\n\n" + - "The impact is more severe than `fetch` (SYN007):\n" + + "The impact is more severe than the `fetch` global bypass:\n" + "- A WebSocket connection persists beyond the fn's return, making the connection lifetime " + "invisible to the caller\n" + "- A fn that constructs a WebSocket has a live, long-lived network dependency that callers " + "cannot see, audit, or mock in tests\n" + "- Audit tools and orchestrators that read `uses { net }` declarations will miss the dependency\n" + "- The connection can receive data and trigger callbacks after the constructing fn has returned\n\n" + - "This is the same bypass class as `fetch` (SYN007) and `console.*` (SYN003): real network " + + "This is the same bypass class as bare `fetch(...)` calls and `console.*` (SYN003): real network " + "effects that sidestep the declared capability surface.\n\n" + "**Fix:** wrap the `WebSocket` constructor in `unsafe \"wraps WebSocket directly\" { new WebSocket(url) }` " + "to make the escape hatch visible in the diff. Note that simply adding `uses { net }` to the fn header " + From 44e708ac146108b0063a220c2b86bfb3f73234e1 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 11 Jun 2026 07:34:35 -0300 Subject: [PATCH 4/9] fix(compiler): add WebSocket?.() optional-call-with-type-args detection for SYN008 The questionDot handler only matched `WebSocket?.(...)` but not `WebSocket?.(...)`. Add generic-arg scanning (matching the existing `(...)` branch) inside the questionDot branch, plus a regression test. --- packages/compiler/src/passes/syn-check.ts | 21 +++++++++++++++++--- packages/compiler/tests/syn008-check.test.ts | 10 ++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 031a0042..2f5c2c0c 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -461,9 +461,24 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const afterAngle8 = tokens[afterWsIdx]; if (!afterAngle8 || !(afterAngle8.kind === "open" && afterAngle8.text === "(")) continue; } else if (afterWs.kind === "questionDot") { - // `WebSocket?.(...)` — optional call - const afterQD8 = nextSignificant(tokens, afterWsIdx + 1); - const afterQDTok8 = tokens[afterQD8]; + // `WebSocket?.(...)` or `WebSocket?.(...)` — optional call (with or without type args) + let afterQD8 = nextSignificant(tokens, afterWsIdx + 1); + let afterQDTok8 = tokens[afterQD8]; + if (afterQDTok8 && afterQDTok8.kind === "operator" && afterQDTok8.text === "<") { + let qdAnglDepth = 1; + afterQD8++; + while (afterQD8 < decl.tokenEnd && qdAnglDepth > 0) { + const at8 = tokens[afterQD8]; + if (!at8) { afterQD8++; continue; } + if (at8.kind === "operator" && at8.text === "<") { qdAnglDepth++; } + else if (at8.kind === "operator" && (at8.text === ">" || at8.text === ">>" || at8.text === ">>>")) { + qdAnglDepth -= at8.text.length; + } + afterQD8++; + } + afterQD8 = nextSignificant(tokens, afterQD8); + afterQDTok8 = tokens[afterQD8]; + } if (!afterQDTok8 || !(afterQDTok8.kind === "open" && afterQDTok8.text === "(")) continue; } else if (!(afterWs.kind === "open" && afterWs.text === "(")) { continue; diff --git a/packages/compiler/tests/syn008-check.test.ts b/packages/compiler/tests/syn008-check.test.ts index 41d230d0..f8dbc0e2 100644 --- a/packages/compiler/tests/syn008-check.test.ts +++ b/packages/compiler/tests/syn008-check.test.ts @@ -52,6 +52,16 @@ describe("SYN008 — WebSocket bypasses the net capability model", () => { expect(result.warnings.some((w) => w.code === "SYN008")).toBe(true); }); + it("fires on WebSocket?.(url) optional call with type arguments", () => { + const src = + "?bs 0.7\n" + + "fn subscribe(url: string) -> void {\n" + + " const ws = WebSocket?.(url)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(true); + }); + it("does not fire below ?bs 0.7", () => { const src = "?bs 0.6\n" + From 49613eac84763a645ffb3edeeaf5e96682217e7b Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 11 Jun 2026 11:33:21 -0300 Subject: [PATCH 5/9] fix(syn008): update stale unsafe-fn skip comment to say all SYN checks are skipped Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/syn-check.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 2f5c2c0c..844adc12 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -85,7 +85,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const nesting = computeNesting(program.fns.map((f) => f.decl)); for (const { decl } of program.fns) { - // An `unsafe "reason" fn` body is an explicit acknowledgment — skip SYN002/SYN003/SYN005. + // An `unsafe "reason" fn` body is an explicit acknowledgment — all SYN checks are skipped. // The range-based suppression above also covers nested non-unsafe fns within it, // so this early-continue is kept purely as an optimisation. if (decl.unsafeReason !== undefined) continue; From 45c0e0361bec644104b208aa4612b89e6d2ee767 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 11 Jun 2026 15:48:17 -0300 Subject: [PATCH 6/9] fix(syn008): normalize call-paren token, fix depth underflow, add method-shorthand exclusion - Refactor to track parenIdx8 pointing at the actual `(` token for all forms (direct, generic , optional ?.(), optional-generic ?.()) - Fix depth scan: use Math.max(0, depth - len) to prevent negative depth from >> or >>> composite tokens causing premature scan exit - Add method-shorthand exclusion: `{ WebSocket(url) { ... } }` no longer fires - Shorten the diagnostic message to its actionable core - Add two regression tests: method-shorthand exclusion and depth-3 generic Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/syn-check.ts | 84 ++++++++++++-------- packages/compiler/tests/syn008-check.test.ts | 20 +++++ 2 files changed, 69 insertions(+), 35 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 844adc12..bd764fa2 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -439,51 +439,66 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult // Must be followed by `(`, `?.(`, or `(` — confirming this is a // constructor or direct call, not a bare `WebSocket` reference. - let afterWsIdx = nextSignificant(tokens, i + 1); - const afterWs = tokens[afterWsIdx]; + // parenIdx8 is normalized to the actual `(` token index for all forms. + const afterWsFirstIdx = nextSignificant(tokens, i + 1); + const afterWs = tokens[afterWsFirstIdx]; if (!afterWs) continue; - // TypeScript instantiation form: `WebSocket(...)` or `new WebSocket(...)` + let parenIdx8: number; + if (afterWs.kind === "operator" && afterWs.text === "<") { + // TypeScript instantiation form: `WebSocket(...)` or `new WebSocket(...)` let anglDepth = 1; - afterWsIdx++; - while (afterWsIdx < decl.tokenEnd && anglDepth > 0) { - const at = tokens[afterWsIdx]; - if (!at) { afterWsIdx++; continue; } - if (at.kind === "operator" && at.text === "<") { anglDepth++; } - else if (at.kind === "operator" && at.text === ">") { anglDepth--; } - else if (at.kind === "operator" && (at.text === ">>" || at.text === ">>>")) { - anglDepth -= at.text.length; - } - afterWsIdx++; + let j = afterWsFirstIdx + 1; + while (j < decl.tokenEnd && anglDepth > 0) { + const at = tokens[j]; + if (!at) { j++; continue; } + if (at.kind === "operator" && at.text === "<") anglDepth++; + else if (at.kind === "operator" && (at.text === ">" || at.text === ">>" || at.text === ">>>")) + anglDepth = Math.max(0, anglDepth - at.text.length); + j++; } - afterWsIdx = nextSignificant(tokens, afterWsIdx); - const afterAngle8 = tokens[afterWsIdx]; - if (!afterAngle8 || !(afterAngle8.kind === "open" && afterAngle8.text === "(")) continue; + parenIdx8 = nextSignificant(tokens, j); } else if (afterWs.kind === "questionDot") { - // `WebSocket?.(...)` or `WebSocket?.(...)` — optional call (with or without type args) - let afterQD8 = nextSignificant(tokens, afterWsIdx + 1); - let afterQDTok8 = tokens[afterQD8]; + // `WebSocket?.(...)` or `WebSocket?.(...)` — optional call + let afterQD8 = nextSignificant(tokens, afterWsFirstIdx + 1); + const afterQDTok8 = tokens[afterQD8]; if (afterQDTok8 && afterQDTok8.kind === "operator" && afterQDTok8.text === "<") { let qdAnglDepth = 1; - afterQD8++; - while (afterQD8 < decl.tokenEnd && qdAnglDepth > 0) { - const at8 = tokens[afterQD8]; - if (!at8) { afterQD8++; continue; } - if (at8.kind === "operator" && at8.text === "<") { qdAnglDepth++; } - else if (at8.kind === "operator" && (at8.text === ">" || at8.text === ">>" || at8.text === ">>>")) { - qdAnglDepth -= at8.text.length; - } - afterQD8++; + let k = afterQD8 + 1; + while (k < decl.tokenEnd && qdAnglDepth > 0) { + const at8 = tokens[k]; + if (!at8) { k++; continue; } + if (at8.kind === "operator" && at8.text === "<") qdAnglDepth++; + else if (at8.kind === "operator" && (at8.text === ">" || at8.text === ">>" || at8.text === ">>>")) + qdAnglDepth = Math.max(0, qdAnglDepth - at8.text.length); + k++; } - afterQD8 = nextSignificant(tokens, afterQD8); - afterQDTok8 = tokens[afterQD8]; + afterQD8 = nextSignificant(tokens, k); } - if (!afterQDTok8 || !(afterQDTok8.kind === "open" && afterQDTok8.text === "(")) continue; - } else if (!(afterWs.kind === "open" && afterWs.text === "(")) { + parenIdx8 = afterQD8; + } else if (afterWs.kind === "open" && afterWs.text === "(") { + parenIdx8 = afterWsFirstIdx; + } else { continue; } + const parenTok8 = tokens[parenIdx8]; + if (!parenTok8 || !(parenTok8.kind === "open" && parenTok8.text === "(")) continue; + + // Exclude object/class method shorthands: `{ WebSocket(url) { ... } }` + const closeParenIdx8 = parenTok8.matchedAt; + if (closeParenIdx8 !== undefined) { + const afterParenIdx8 = nextSignificant(tokens, closeParenIdx8 + 1); + const afterParen8 = tokens[afterParenIdx8]; + if ( + afterParen8 && + ((afterParen8.kind === "open" && afterParen8.text === "{") || + afterParen8.kind === "fatArrow" || + (afterParen8.kind === "punct" && afterParen8.text === ":")) + ) continue; + } + // Suppression check: unsafe block or unsafe fn body if (isInsideRange(tok8.start, unsafeRanges)) continue; @@ -497,9 +512,8 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult start: tok8.start, end: tok8.end, message: - `fn '${decl.name}' constructs a WebSocket — WebSocket bypasses the net capability model; ` + - `CAP001 cannot see it; wrap in ` + - `unsafe "wraps WebSocket directly" { new WebSocket(url) }, or write a $require("net")-checked wrapper`, + `fn '${decl.name}' constructs a WebSocket — bypasses the net capability model; ` + + `wrap in unsafe "wraps WebSocket directly" { new WebSocket(url) } or write a $require("net")-checked wrapper`, rule: syn008.rule, idiom: syn008.idiom, rewrite: syn008.rewrite, diff --git a/packages/compiler/tests/syn008-check.test.ts b/packages/compiler/tests/syn008-check.test.ts index f8dbc0e2..47d7b75a 100644 --- a/packages/compiler/tests/syn008-check.test.ts +++ b/packages/compiler/tests/syn008-check.test.ts @@ -123,4 +123,24 @@ describe("SYN008 — WebSocket bypasses the net capability model", () => { expect(w).toBeDefined(); expect(w!.severity).toBe("warning"); }); + + it("does NOT fire on WebSocket() as an object-literal method shorthand inside a fn body", () => { + const src = + "?bs 0.7\n" + + "fn makeClient() -> any {\n" + + " return { WebSocket(url: string) { return url } }\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(false); + }); + + it("fires on WebSocket>>(url) — depth-3 generic with >>> composite token", () => { + const src = + "?bs 0.7\n" + + "fn connect(url: string) -> void {\n" + + " const ws = new WebSocket>>(url)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(true); + }); }); From a1ab2942a015001e7f4bea3d5dcba77b136c7d68 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 11 Jun 2026 19:40:19 -0300 Subject: [PATCH 7/9] fix(compiler): address SYN008 Copilot review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - syn-check.ts: update inline comment to list all detection forms including `WebSocket?.(...)` (optional-call-with-type-args) - syn-check.ts: change diagnostic message from "constructs" to "calls or constructs" to cover bare `WebSocket(url)` (no `new`) - error-codes.ts: rephrase rule text — replace the misleading "no `uses { net }` reflects it" with "no `http.*` call is present to justify a `uses { net }` declaration" to clarify the constraint - explanations.ts: rephrase Fix note so it doesn't imply you can't add `uses { net }`; clarify that the compiler cannot verify the net usage without an `http.*` call, and CAP002 will fire - explanations.ts: add `?.(` to the detection description - AGENTS.md: update SYN008 table row to include `WebSocket?.(url)` form and use "calls or constructs" Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 2 +- packages/compiler/src/error-codes.ts | 2 +- packages/compiler/src/passes/syn-check.ts | 6 +++--- packages/mcp/src/explanations.ts | 10 +++++----- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d16e3541..b6f2fd53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -200,7 +200,7 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope. | SYN003 | (0.7+, warning) A fn body contains a `console.*` call (console.log, console.error, etc.). Direct console output bypasses the `stdout`/`stderr` capability model — the compiler cannot enforce or surface the output declaration for callers. | Replace `console.log(...)` with `stdout.write(...)` and add `uses { stdout }` to the fn header; replace `console.error(...)` with `stderr.write(...)` and add `uses { stderr }`. | | SYN005 | (0.7+, warning) A fn body accesses `process.env`. `process.env` is a global deployment-environment namespace — access is invisible to callers, there is no capability or resource declaration that covers it, and the fn has an undeclared dependency on deployment configuration. Detection: `process` not preceded by `.`/`?.`, followed by `.`/`?.` then `env`. `obj.process.env` (member access on a local), `unsafe {}` blocks, and `unsafe "reason" fn` bodies are excluded. | Pass config and secrets as explicit fn parameters so the dependency is visible in the call signature; if env access is required at the load site, wrap in `unsafe "reads deployment env" { }`. | | SYN006 | (0.7+, warning) A fn body calls `process.exit()`, `process?.exit()`, or `process.exit?.()`. All forms terminate the entire host process — not just the fn, not just the bot. They produce no return value, bypass `Result` propagation, `throws {}`, `match`, and any caller recovery path. No capability declaration covers them. Detection: `process` not preceded by `.`/`?.`, followed by `.`/`?.` then `exit` then `(` or `?.(`. `obj.process.exit(...)`, `process.exit` without `(`, and `process.exitCode` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Return `err(...)` and let the caller decide whether to terminate. If `process.exit` is genuinely required at a bootstrap entry point, wrap in `unsafe "exits on invalid config" { process.exit(1) }`. | -| SYN008 | (0.7+, warning) A fn body constructs a `WebSocket` via `new WebSocket(url)`, `WebSocket(url)`, or TypeScript instantiation forms like `new WebSocket(url)`. All forms open persistent bidirectional connections at runtime but are invisible to CAP001 (which checks `http.*` member calls). A fn that constructs a WebSocket has an undeclared `net` dependency. Detection: `WebSocket` not preceded by `.`/`?.`, followed by `(`, `?.(`, or `(`. `obj.WebSocket(...)` and bare `WebSocket` references are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Wrap the constructor in `unsafe "wraps WebSocket directly" { new WebSocket(url) }` to make the escape hatch visible in the diff; note that `uses { net }` alone will trigger CAP002 without a stdlib `http.*` call; write a thin `$require("net")`-checked wrapper for full capability tracking. | +| SYN008 | (0.7+, warning) A fn body calls or constructs a `WebSocket` via `new WebSocket(url)`, `WebSocket(url)`, TypeScript instantiation forms like `new WebSocket(url)`, or optional-call-with-type-args `WebSocket?.(url)`. All forms open persistent bidirectional connections at runtime but are invisible to CAP001 (which checks `http.*` member calls). A fn that calls or constructs a WebSocket has an undeclared `net` dependency. Detection: `WebSocket` not preceded by `.`/`?.`, followed by `(`, `?.(`, `(`, or `?.(`. `obj.WebSocket(...)` and bare `WebSocket` references are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Wrap the constructor in `unsafe "wraps WebSocket directly" { new WebSocket(url) }` to make the escape hatch visible in the diff; note that `uses { net }` alone will trigger CAP002 without a stdlib `http.*` call; write a thin `$require("net")`-checked wrapper for full capability tracking. | | INT002 | (0.7+) A fn declares `intent: "pure"` but its body directly references a stdlib capability (e.g. `http.get`, `fs.read`). Pure intent is enforced at the body level as well as the header. | Remove the stdlib call from the body, or change the intent. | | INT003 | (0.7+) A fn declares `intent: "idempotent"` but also has `uses { random }` or `uses { time }`. Both capabilities produce different values on each call, making the function non-idempotent. Only `random` and `time` are flagged; other capabilities are not structurally flagged by this check (INT003 is a narrow heuristic, not a proof of idempotence). | Remove `random`/`time` from `uses {}`, or change the intent. | | INT004 | (0.7+) A fn declares `intent: "idempotent"` but its body directly references `random` or `time` without declaring them. Under-declaration variant of INT003 — fires when INT003 does not. | Remove the non-idempotent call from the body, or declare the capability and remove the idempotent intent. | diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index 48ece019..d1d0e69d 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -619,7 +619,7 @@ const E: Record = { "`new WebSocket(url)` open persistent bidirectional connections at runtime but are " + "invisible to botscript's capability model: CAP001 checks for `http.*` member calls, " + "not the `WebSocket` global. A fn that constructs a WebSocket has an undeclared network " + - "dependency — no `uses { net }` reflects it, no audit tool can see it, and the connection " + + "dependency — no `http.*` call is present to justify a `uses { net }` declaration, no audit tool can see it, and the connection " + "outlives the fn's return value.", idiom: "wrap the `WebSocket` constructor in `unsafe \"wraps WebSocket directly\" { new WebSocket(url) }` " + diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index bd764fa2..2b420284 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -437,8 +437,8 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult if (prev8 && ((prev8.kind === "punct" && prev8.text === ".") || prev8.kind === "questionDot")) continue; - // Must be followed by `(`, `?.(`, or `(` — confirming this is a - // constructor or direct call, not a bare `WebSocket` reference. + // Must be followed by `(`, `?.(`, `(`, or `?.(` — confirming this is a + // constructor or direct call (including optional-call-with-type-args), not a bare `WebSocket` reference. // parenIdx8 is normalized to the actual `(` token index for all forms. const afterWsFirstIdx = nextSignificant(tokens, i + 1); const afterWs = tokens[afterWsFirstIdx]; @@ -512,7 +512,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult start: tok8.start, end: tok8.end, message: - `fn '${decl.name}' constructs a WebSocket — bypasses the net capability model; ` + + `fn '${decl.name}' calls or constructs a WebSocket — bypasses the net capability model; ` + `wrap in unsafe "wraps WebSocket directly" { new WebSocket(url) } or write a $require("net")-checked wrapper`, rule: syn008.rule, idiom: syn008.idiom, diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index 4aab6d00..292cac50 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -830,12 +830,12 @@ export const EXPLANATIONS: Readonly> = { "This is the same bypass class as bare `fetch(...)` calls and `console.*` (SYN003): real network " + "effects that sidestep the declared capability surface.\n\n" + "**Fix:** wrap the `WebSocket` constructor in `unsafe \"wraps WebSocket directly\" { new WebSocket(url) }` " + - "to make the escape hatch visible in the diff. Note that simply adding `uses { net }` to the fn header " + - "without an `http.*` call will trigger CAP002 (over-declared capability) — the stdlib currently has no " + - "WebSocket wrapper. For full capability tracking, write a thin wrapper fn that calls `$require(\"net\")` " + - "before constructing the socket.\n\n" + + "to make the escape hatch visible in the diff. You can add `uses { net }` to the fn header, but without " + + "an `http.*` call the compiler cannot verify the net usage and CAP002 (over-declared capability) will fire — " + + "the stdlib currently has no WebSocket wrapper. For full capability tracking, write a thin wrapper fn that " + + "calls `$require(\"net\")` before constructing the socket.\n\n" + "SYN008 fires at `?bs 0.7+` as a non-blocking warning. Detection is token-based: " + - "`WebSocket` not preceded by `.`/`?.` followed by `(`, `?.(`, or `(`. " + + "`WebSocket` not preceded by `.`/`?.` followed by `(`, `?.(`, `(`, or `?.(`. " + "Both `new WebSocket(url)` and `WebSocket(url)` (without `new`) are detected. " + "`obj.WebSocket(...)` (method call on a local object) is excluded. " + "Calls inside `unsafe { }` blocks or `unsafe \"reason\" fn` bodies are suppressed.", From e1b22b38284630244a24a33d573238734f106776 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 12 Jun 2026 03:46:18 -0300 Subject: [PATCH 8/9] fix(compiler): address remaining SYN008 Copilot review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - syn-check.ts: expand detection comment to list all five forms including `WebSocket?.(...)` and `WebSocket?.(...)` (was missing optional-call forms) - error-codes.ts: rephrase SYN008 rule text to avoid implying `uses { net }` is impossible to declare — say "the capability model cannot see it" instead of "no uses { net } reflects it" - explanations.ts: rephrase line 822 from "`uses { net }` is absent from the fn header" to "the capability model cannot require `uses { net }` for it" - explanations.ts: expand detection description to enumerate all five detected surface forms including `WebSocket?.(url)` (optional-call-with-type-args) Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/error-codes.ts | 4 ++-- packages/compiler/src/passes/syn-check.ts | 5 +++-- packages/mcp/src/explanations.ts | 8 +++++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index d1d0e69d..6a2bfb6c 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -619,8 +619,8 @@ const E: Record = { "`new WebSocket(url)` open persistent bidirectional connections at runtime but are " + "invisible to botscript's capability model: CAP001 checks for `http.*` member calls, " + "not the `WebSocket` global. A fn that constructs a WebSocket has an undeclared network " + - "dependency — no `http.*` call is present to justify a `uses { net }` declaration, no audit tool can see it, and the connection " + - "outlives the fn's return value.", + "dependency — the capability model cannot see it (there is no `http.*` call for CAP001 to track), " + + "no audit tool can observe the dependency from the fn header, and the connection outlives the fn's return value.", idiom: "wrap the `WebSocket` constructor in `unsafe \"wraps WebSocket directly\" { new WebSocket(url) }` " + "to make the escape hatch visible in the diff; for full capability tracking, write a thin " + diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 2b420284..13d544bf 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -413,8 +413,9 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult } // SYN008: WebSocket constructor/call detection. - // Fires when a fn body contains `new WebSocket(...)`, `WebSocket(...)`, or - // TypeScript instantiation form `new WebSocket(...)` / `WebSocket(...)`. + // Fires when a fn body contains `new WebSocket(...)`, `WebSocket(...)`, + // TypeScript instantiation form `new WebSocket(...)` / `WebSocket(...)`, + // optional-call `WebSocket?.(...)`, or optional-call-with-type-args `WebSocket?.(...)`. // All forms open a persistent bidirectional network connection at runtime // but are invisible to CAP001 (which checks `http.*` member calls only). // Suppressed inside `unsafe { }` blocks and `unsafe fn` bodies. diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index 292cac50..6a80ed1f 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -819,7 +819,7 @@ export const EXPLANATIONS: Readonly> = { "Botscript's capability model maps network access to `uses { net }` via the `http` stdlib " + "namespace. CAP001 enforces this by checking for `http.*` member calls (e.g. `http.get()`, " + "`http.post()`). But the `WebSocket` global opens a persistent bidirectional connection without " + - "any `http.*` call — CAP001 never fires, and `uses { net }` is absent from the fn header.\n\n" + + "any `http.*` call — CAP001 never fires, and the capability model cannot require `uses { net }` for it.\n\n" + "The impact is more severe than the `fetch` global bypass:\n" + "- A WebSocket connection persists beyond the fn's return, making the connection lifetime " + "invisible to the caller\n" + @@ -835,8 +835,10 @@ export const EXPLANATIONS: Readonly> = { "the stdlib currently has no WebSocket wrapper. For full capability tracking, write a thin wrapper fn that " + "calls `$require(\"net\")` before constructing the socket.\n\n" + "SYN008 fires at `?bs 0.7+` as a non-blocking warning. Detection is token-based: " + - "`WebSocket` not preceded by `.`/`?.` followed by `(`, `?.(`, `(`, or `?.(`. " + - "Both `new WebSocket(url)` and `WebSocket(url)` (without `new`) are detected. " + + "`WebSocket` not preceded by `.`/`?.`, followed by `(`, `?.(`, `(`, or `?.(`. " + + "All five surface forms are detected: `new WebSocket(url)`, `WebSocket(url)` (without `new`), " + + "`new WebSocket(url)` (generic instantiation), `WebSocket?.(url)` (optional call), and " + + "`WebSocket?.(url)` (optional-call-with-type-args). " + "`obj.WebSocket(...)` (method call on a local object) is excluded. " + "Calls inside `unsafe { }` blocks or `unsafe \"reason\" fn` bodies are suppressed.", example: { From daa655b668aff922a41518f6676c6953f3ed0554 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 12 Jun 2026 07:32:59 -0300 Subject: [PATCH 9/9] fix(syn008): generalize message example and fix explanation bypass-class description Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/syn-check.ts | 2 +- packages/mcp/src/explanations.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 13d544bf..d811ddaf 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -514,7 +514,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult end: tok8.end, message: `fn '${decl.name}' calls or constructs a WebSocket — bypasses the net capability model; ` + - `wrap in unsafe "wraps WebSocket directly" { new WebSocket(url) } or write a $require("net")-checked wrapper`, + `wrap in unsafe "wraps WebSocket directly" { WebSocket(...) } or write a $require("net")-checked wrapper`, rule: syn008.rule, idiom: syn008.idiom, rewrite: syn008.rewrite, diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index 6a80ed1f..23289744 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -827,8 +827,8 @@ export const EXPLANATIONS: Readonly> = { "cannot see, audit, or mock in tests\n" + "- Audit tools and orchestrators that read `uses { net }` declarations will miss the dependency\n" + "- The connection can receive data and trigger callbacks after the constructing fn has returned\n\n" + - "This is the same bypass class as bare `fetch(...)` calls and `console.*` (SYN003): real network " + - "effects that sidestep the declared capability surface.\n\n" + + "This is the same bypass class as bare `fetch(...)` calls: network effects that sidestep the " + + "declared capability surface.\n\n" + "**Fix:** wrap the `WebSocket` constructor in `unsafe \"wraps WebSocket directly\" { new WebSocket(url) }` " + "to make the escape hatch visible in the diff. You can add `uses { net }` to the fn header, but without " + "an `http.*` call the compiler cannot verify the net usage and CAP002 (over-declared capability) will fire — " +