diff --git a/AGENTS.md b/AGENTS.md index f4fa3266..84d6d6ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -201,6 +201,7 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope. | SYN004 | (0.7+, warning) A fn body calls `eval(...)` / `eval?.(...)` (global eval not preceded by `.`/`?.`) or calls `Function(...)` / `Function?.(...)` / `new Function(...)` (Function constructor not preceded by `.`/`?.`). All forms execute strings as code at runtime — every static capability check (CAP001/CAP002), resource declaration (reads/writes), and safety check (SYN002/SYN003) can be bypassed by routing any unsafe pattern through eval or the Function constructor. Suppressed inside `unsafe {}` blocks and `unsafe fn` bodies. `.eval(...)` (method call on a local) and `Function.*` member accesses are excluded. | Refactor the eval-based pattern to use explicit code paths. If eval is genuinely required (e.g. sandboxed interpreter), wrap in `unsafe "" { eval(...) }`. | | SYN005 | (0.7+, warning) A fn body accesses `process.env`. `process.env` is a global deployment-environment namespace — access is invisible to callers and to static analysis; no capability or resource declaration covers it, so 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) }`. | +| SYN007 | (0.7+, warning) A fn body calls `fetch(url)` or `fetch?.(url)`. `fetch` makes HTTP requests at runtime but is invisible to CAP001, which only checks `http.*` member calls. CAP001 cannot infer or require `uses { net }` from `fetch` calls, so callers cannot rely on CAP001 to detect a missing declaration. Detection: `fetch` not preceded by `.`/`?.`, followed by `(` or `?.(`. Member calls (`obj.fetch(...)`), object method shorthands (`{ fetch(url) {} }`), TypeScript method signatures, fn/function declarations named `fetch`, and bare `fetch` references are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Replace `fetch(url)` with `http.get(url)` / `http.post(url, { body })` and add `uses { net }` to the fn header. If the native fetch API is required, wrap in `unsafe "calls fetch directly" { fetch(url) }`. | | SYN010 | (0.7+, warning) A fn body calls `setTimeout(...)`, `setInterval(...)`, or `queueMicrotask(...)`. These globals schedule callbacks that run after the fn returns — any effects inside those callbacks are invisible to callers: no capability declaration, no `writes {}` label, and no `throws {}` entry can cover them. Detection: identifier not preceded by `.`/`?.`, followed by `(` or `?.(`. Member calls (`obj.setTimeout(...)`) and bare references (without `(`) are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Make the timing explicit: return a `Promise` the caller awaits, or return a teardown function so the caller controls the lifecycle. If a timer is genuinely required, wrap in `unsafe "schedules deferred effect" { setTimeout(...) }`. | | SYN011 | (0.7+, warning) A fn body calls `import(specifier)` — the dynamic import form. Dynamic imports load a module at runtime whose capability surface is unbounded: CAP001 checks for stdlib namespace calls, not dynamic module loads. A fn that calls `import()` has an undeclared capability surface proportional to everything the dynamically loaded module might do at runtime. Detection: `import` token not preceded by `.`/`?.`, followed by `(` or `?.(`. `import.meta` (followed by `.`) is excluded. Object method shorthands and `fn import(...)` declarations are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | If the module is known at compile time, use a static top-level `import { ... } from` declaration instead. If dynamic loading is required, wrap in `unsafe "loads plugin dynamically" { import(specifier) }`. | | 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. | diff --git a/README.md b/README.md index 5968e574..7df89d5b 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`, `SYN010`, `SYN011`, `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`, `SYN010`, `SYN011`, `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 324256f7..5147caa3 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -642,6 +642,38 @@ const E: Record = { " return ok(undefined)\n" + "}", }, + SYN007: { + code: "SYN007", + title: "fetch() call bypasses the net capability model", + rule: + "`fetch(url)` and `fetch?.(url)` make HTTP requests at runtime but are invisible to " + + "botscript's capability model: CAP001 checks for `http.*` member calls, not the `fetch` " + + "global. A fn that calls `fetch` has an undeclared network dependency — CAP001 cannot " + + "infer or require `uses { net }` from `fetch` calls, so callers and audit tooling cannot " + + "rely on CAP001 to detect a missing declaration.", + idiom: + "replace `fetch(url)` with `http.get(url)` (or `http.post(url, { body })`) and add " + + "`uses { net }` to the fn header; if the native fetch API is required, wrap in " + + '`unsafe "calls fetch directly" { fetch(url) }`', + rewrite: + "// before — fetch is invisible to the capability model\n" + + "fn getUser(id: string) -> Promise {\n" + + " return fetch(`/api/users/${id}`).then(r => r.json()) // SYN007\n" + + "}\n\n" + + "// after — declared network dependency\n" + + "fn getUser(id: string) uses { net } -> Promise {\n" + + " return http.get(`/api/users/${id}`)\n" + + "}", + example: + "// SYN007: fetch bypasses the net capability model\n" + + "fn getUser(id: string) -> Promise {\n" + + " return fetch(`/api/users/${id}`).then(r => r.json()) // SYN007\n" + + "}\n\n" + + "// fix: declare the dependency\n" + + "fn getUser(id: string) uses { net } -> Promise {\n" + + " return http.get(`/api/users/${id}`)\n" + + "}", + }, SYN010: { code: "SYN010", title: "setTimeout / setInterval / queueMicrotask defers side effects outside the fn's capability surface", diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 758edb17..78ecac10 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -36,6 +36,15 @@ * path. The idiomatic fix is `return err(...)` so the caller can * decide whether to terminate. * + * SYN007 A `fetch(url)` or `fetch?.(url)` call was detected in a fn body (?bs 0.7+). + * `fetch` makes HTTP requests at runtime but is invisible to botscript's + * capability model: CAP001 checks for `http.*` member calls, not the `fetch` + * global. A fn that calls `fetch` has an undeclared network dependency. + * Excluded: member calls (`obj.fetch`), function/fn declarations named + * `fetch`, object/class method shorthands, and TypeScript method + * signatures (`{ fetch(url): T; }`). The `:` exclusion is guarded + * against ternary consequents (`cond ? fetch(url) : other`). + * * SYN010 A `setTimeout(...)`, `setInterval(...)`, or `queueMicrotask(...)` * call was detected in a fn body (?bs 0.7+). These globals schedule * callbacks to run after the current fn returns — any effects inside @@ -90,6 +99,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const syn004 = getErrorCode("SYN004")!; const syn005 = getErrorCode("SYN005")!; const syn006 = getErrorCode("SYN006")!; + const syn007 = getErrorCode("SYN007")!; const syn010 = getErrorCode("SYN010")!; const syn011 = getErrorCode("SYN011")!; @@ -510,6 +520,74 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult break; } + // ── SYN007: fetch() call ───────────────────────────────────────────── + case "fetch": { + const prevIdx7 = prevSignificant(tokens, i - 1); + const prev7 = tokens[prevIdx7]; + + // Exclude: `obj.fetch(...)` — preceded by `.` or `?.` + if (prev7 && ((prev7.kind === "punct" && prev7.text === ".") || prev7.kind === "questionDot")) + continue; + + // Exclude: function/fn declarations named fetch + if (prev7 && prev7.kind === "ident" && prev7.text === "function") continue; + if (prev7 && prev7.kind === "keyword" && prev7.text === "fn") continue; + + // Must be followed by `(` or `?.(` — confirming this is a call. + const nextIdx7 = nextSignificant(tokens, i + 1); + const next7 = tokens[nextIdx7]; + + let callIdx7 = nextIdx7; + let isOpt7 = false; + if (next7 && next7.kind === "questionDot") { + isOpt7 = true; + callIdx7 = nextSignificant(tokens, nextIdx7 + 1); + } + const callTok7 = tokens[callIdx7]; + if (!callTok7 || !(callTok7.kind === "open" && callTok7.text === "(")) continue; + + // Exclude method shorthands and TS method signatures: { fetch(url) { } } / { fetch(url): T; } + // Guard the `:` check against ternary consequents: `cond ? fetch(url) : other` + // Also handles `cond ? await fetch(url) : other` — if prev is `await`, look one further back. + const prevBeforeAwait7 = (prev7 && prev7.kind === "ident" && prev7.text === "await") + ? tokens[prevSignificant(tokens, prevIdx7 - 1)] + : undefined; + const isTernaryConsequent7 = (prev7 !== undefined && prev7 !== null && prev7.kind === "question") || + (prevBeforeAwait7 !== undefined && prevBeforeAwait7 !== null && prevBeforeAwait7.kind === "question"); + if (callTok7.matchedAt !== undefined) { + const afterCloseIdx7 = nextSignificant(tokens, callTok7.matchedAt + 1); + const afterClose7 = tokens[afterCloseIdx7]; + if (afterClose7 && ( + (afterClose7.kind === "open" && afterClose7.text === "{") || + afterClose7.kind === "fatArrow" || + (!isTernaryConsequent7 && afterClose7.kind === "punct" && afterClose7.text === ":") + )) continue; + } + + if (isInsideRange(tok.start, unsafeRanges)) continue; + + const callSep7 = isOpt7 ? "?." : ""; + const loc7 = locationOf(src, tok.start); + warnings.push({ + code: "SYN007", + severity: "warning", + file: null, + line: loc7.line, + column: loc7.column, + start: tok.start, + end: callTok7.start + 1, + message: + `fn '${decl.name}' calls fetch${callSep7}() — ` + + `fetch makes an HTTP request invisible to the capability model; ` + + `replace with http.get(url)/http.post(url, { body }) and add uses { net }, ` + + `or wrap in unsafe "calls fetch directly" { fetch(url) }`, + rule: syn007.rule, + idiom: syn007.idiom, + rewrite: syn007.rewrite, + }); + break; + } + // ── SYN011: dynamic import() call ──────────────────────────────────── case "import": { // Exclude: `obj.import(...)` — preceded by `.` or `?.` diff --git a/packages/compiler/tests/error-codes.test.ts b/packages/compiler/tests/error-codes.test.ts index 88c0e827..0b4e6936 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", "SYN010", "SYN011", + "SYN001", "SYN002", "SYN003", "SYN004", "SYN005", "SYN006", "SYN007", "SYN010", "SYN011", "THR001", "THR002", "THR003", "THR004", "UNS001", "UNS002", "UNS003", "UNS004", "UNS005", "VER001", "VER002", "VER003", diff --git a/packages/compiler/tests/syn007-check.test.ts b/packages/compiler/tests/syn007-check.test.ts new file mode 100644 index 00000000..5b4b933b --- /dev/null +++ b/packages/compiler/tests/syn007-check.test.ts @@ -0,0 +1,172 @@ +/** + * Tests for SYN007: fetch() call detection (?bs 0.7+). + * + * SYN007 is a non-blocking warning — transform must not throw. + */ + +import { describe, it, expect } from "vitest"; +import { transform } from "../src/index.js"; + +function compile(src: string) { + return transform(src, {}); +} + +describe("SYN007: fetch() call detection", () => { + it("fires on bare fetch(url) inside a fn body", () => { + const src = + "?bs 0.7\n" + + "fn getUser(id: string) -> any {\n" + + " return fetch(`/api/users/${id}`)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN007")).toBe(true); + }); + + it("fires on awaited fetch(url)", () => { + const src = + "?bs 0.7\n" + + "async fn getUser(url: string) -> any {\n" + + " const resp = await fetch(url)\n" + + " return resp\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN007")).toBe(true); + }); + + it("fires on optional-call form fetch?.(url)", () => { + const src = + "?bs 0.7\n" + + "async fn getUser(url: string) -> any {\n" + + " return fetch?.(url)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN007")).toBe(true); + }); + + it("does NOT fire inside an unsafe block", () => { + const src = + "?bs 0.7\n" + + "async fn getUser(url: string) -> any {\n" + + ' return unsafe "calls fetch directly" { fetch(url) }\n' + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN007")).toBe(false); + }); + + it("does NOT fire inside an unsafe fn body", () => { + const src = + "?bs 0.7\n" + + 'unsafe "calls fetch directly" async fn getUser(url: string) -> any {\n' + + " return fetch(url)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN007")).toBe(false); + }); + + it("does NOT fire on member call obj.fetch(url)", () => { + const src = + "?bs 0.7\n" + + "fn getUser(client: any) -> any {\n" + + " return client.fetch('/api')\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN007")).toBe(false); + }); + + it("does NOT fire on bare fetch reference (not called)", () => { + const src = + "?bs 0.7\n" + + "fn getFetch() -> any {\n" + + " return fetch\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN007")).toBe(false); + }); + + it("does NOT fire below ?bs 0.7", () => { + const src = + "?bs 0.6\n" + + "fn getUser(url: string) -> any {\n" + + " return fetch(url)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN007")).toBe(false); + }); + + it("severity is warning (non-blocking)", () => { + const src = + "?bs 0.7\n" + + "fn getUser(url: string) -> any {\n" + + " return fetch(url)\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN007"); + expect(w?.severity).toBe("warning"); + }); + + it("does NOT fire on object method shorthand named fetch", () => { + const src = + "?bs 0.7\n" + + "fn test() -> void {\n" + + " const client = { fetch(url) { return url; } };\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN007")).toBe(false); + }); + + it("does NOT fire on TypeScript method signature named fetch", () => { + const src = + "?bs 0.7\n" + + "fn test() -> void {\n" + + " type Client = { fetch(url: string): Promise };\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN007")).toBe(false); + }); + + it("does NOT fire on a botscript fn declaration named fetch", () => { + const src = + "?bs 0.7\n" + + "fn fetch(url: string) -> any {\n" + + " return url\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN007")).toBe(false); + }); + + it("fires on fetch() inside a ternary expression (regression: `:` must not suppress)", () => { + // `cond ? fetch(a) : fetch(b)` — the `:` after fetch(a)'s closing `)` used to + // incorrectly match the TS method-signature exclusion, hiding SYN007. + const src = + "?bs 0.7\n" + + "fn pick(cond: boolean, a: string, b: string) -> any {\n" + + " return cond ? fetch(a) : fetch(b)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.filter((w) => w.code === "SYN007").length).toBe(2); + }); + + it("fires on await fetch() inside a ternary expression (regression: await must not hide ternary context)", () => { + // `cond ? await fetch(a) : other` — prev token before fetch is `await`, not `?`. + // The ternary guard must look one token further back when prev is `await`. + const src = + "?bs 0.7\n" + + "async fn pick(cond: boolean, a: string, b: string) -> any {\n" + + " return cond ? await fetch(a) : await fetch(b)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.filter((w) => w.code === "SYN007").length).toBe(2); + }); + + it("fires once per distinct fetch() call in the same fn", () => { + const src = + "?bs 0.7\n" + + "async fn loadBoth(a: string, b: string) -> any {\n" + + " const r1 = await fetch(a)\n" + + " const r2 = await fetch(b)\n" + + " return { r1, r2 }\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.filter((w) => w.code === "SYN007").length).toBe(2); + }); +}); diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index a384e82d..4c9d707c 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -849,6 +849,38 @@ export const EXPLANATIONS: Readonly> = { "}\n", }, }, + SYN007: { + code: "SYN007", + title: "fetch() call bypasses the net capability model", + body: + "Botscript's capability model is static: the compiler reads the fn header and infers what that fn " + + "may do — network access, resource reads/writes, error types. The `fetch` global bypasses this " + + "by making HTTP requests at runtime that CAP001 cannot see.\n\n" + + "CAP001 checks for `http.*` member calls (the stdlib's declared network surface). `fetch` is a " + + "browser/Node global — calling it does not require a `uses { net }` declaration, and the compiler " + + "cannot enforce that callers know the fn has a network dependency.\n\n" + + "**Fix:** replace `fetch(url)` with `http.get(url)` (or `http.post(url, { body })`) and add " + + "`uses { net }` to the fn header. If the native fetch API is genuinely required (e.g. for " + + "streaming, credentials, or non-standard headers), wrap in " + + "`unsafe \"calls fetch directly\" { fetch(url) }` to make the escape hatch visible.\n\n" + + "SYN007 fires at `?bs 0.7+` as a non-blocking warning. Detection is token-based: `fetch` not " + + "preceded by `.`/`?.` (member call exclusion), followed by `(` or `?.(`. " + + "Object method shorthands (`{ fetch(url) {} }`), TypeScript method signatures, fn/function " + + "declarations named `fetch`, and bare `fetch` references are excluded. " + + "Calls inside `unsafe { }` blocks or `unsafe \"reason\" fn` bodies are suppressed.", + example: { + fails: + "?bs 0.7\n" + + "fn getUser(url: string) -> any {\n" + + " return fetch(url)\n" + + "}\n", + passes: + "?bs 0.7\n" + + "fn getUser(url: string) uses { net } -> any {\n" + + " return http.get(url)\n" + + "}\n", + }, + }, SYN010: { code: "SYN010", title: "setTimeout / setInterval / queueMicrotask defers side effects outside the fn's capability surface", diff --git a/packages/mcp/tests/server.test.ts b/packages/mcp/tests/server.test.ts index 744cc37a..7e2ad742 100644 --- a/packages/mcp/tests/server.test.ts +++ b/packages/mcp/tests/server.test.ts @@ -78,6 +78,7 @@ describe("botscript-mcp explanations", () => { "SYN004", "SYN005", "SYN006", + "SYN007", "SYN010", "SYN011", "THR001",