Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
bc1bae2
feat(compiler): SYN015 — warn on localStorage/sessionStorage access t…
Jun 13, 2026
a232932
fix(syn015): clarify sessionStorage is per-tab, not cross-session per…
Jun 14, 2026
fa682a1
fix(syn015): address Copilot review — block comment accuracy, diagnos…
Jun 14, 2026
dffb842
fix(syn015): address Copilot review — use generic method(...) in diag…
Jun 14, 2026
6469a4d
fix(compiler): SYN015 — extend range to include access operator, use …
Jun 14, 2026
8a80ad6
fix(syn015): exclude local bindings named localStorage/sessionStorage
Jun 15, 2026
1efc3e1
fix(syn015): include actual member name in diagnostic range and message
Jun 15, 2026
86ed355
fix(syn015): correct unsafe example for property accesses and indexed…
Jun 15, 2026
5c51b34
fix(syn015): exclude generator declarations; fix optional-chain separ…
Jun 15, 2026
cac5335
fix(syn015): lazy localBindings scan; replace duplicate test; documen…
Jun 16, 2026
4bbb583
fix(compiler): SYN015 — handle computed member access localStorage?.[…
Jun 17, 2026
e56ba44
Merge origin/main into syn015-localstorage-bypass
Jun 17, 2026
b2f0e59
fix(compiler): SYN015 detect localStorage[key] non-optional computed …
Jun 17, 2026
f0b52b7
fix(compiler): document computed member access in SYN015 detection de…
Jun 17, 2026
eb40138
fix(compiler): SYN015 — reorder member-access check before localBindi…
Jun 17, 2026
4afcca9
fix(syn015): track var bindings in nested blocks (var is fn-scoped)
Jun 18, 2026
e7a8694
fix(syn015): simplify local-binding suppression to param-names only
Jun 18, 2026
a40a4f1
docs(syn015): align doc comments with implementation — only parameter…
Jun 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope.
| SYN012 | (0.7+, warning) A fn body constructs an `EventSource` via `new EventSource(url)`, bare `EventSource(url)`, `EventSource?.(url)`, or TypeScript instantiation form `new EventSource<T>(url)`. EventSource opens a persistent server-sent-events (SSE) connection at runtime but is invisible to CAP001 — the capability model only checks `http.*` member calls. Detection: `EventSource` not preceded by `.`/`?.`, followed by `(`, `?.(`, or `<T>(` (generic scan gated on `new`). Member calls, `function`/`fn` declarations, object method shorthands, and TS method signatures are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Wrap in `unsafe "wraps EventSource for <reason>" { new EventSource(url) }` to make the escape hatch visible in the diff. |
| SYN013 | (0.7+, warning) A fn body constructs a `Worker` or `SharedWorker` via `new Worker(scriptURL)`, bare `Worker(scriptURL)`, `Worker?.(scriptURL)`, `new SharedWorker(scriptURL)`, `SharedWorker?.(scriptURL)`, or TypeScript instantiation forms. Worker construction spawns a new JS execution context whose capability surface is unbounded — the worker script can make network requests, access storage, and perform any operation, none of which is visible in the spawning fn's `uses {}`, `reads {}`, or `writes {}` declarations. CAP001 cannot infer any capability from worker construction. Detection: `Worker` or `SharedWorker` not preceded by `.`/`?.`, followed by `(`, `?.(`, or `<T>(` (generic scan gated on `new`). Member calls, `function`/`fn`/`function*` declarations, object method shorthands, and TS method signatures are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Wrap in `unsafe "<reason>" { new Worker(scriptURL) }` with a reason that documents what capabilities the worker script is expected to use. |
| SYN014 | (0.7+, warning) A fn body calls `new BroadcastChannel(name)`, `BroadcastChannel(name)`, or TypeScript instantiation form `new BroadcastChannel<T>(name)`. `BroadcastChannel` opens a cross-context message channel at runtime — any tab, window, or worker on the same origin can post to or receive from it — invisible to CAP001. A fn that constructs a BroadcastChannel has an undeclared cross-context messaging dependency. Detection: `BroadcastChannel` not preceded by `.`/`?.`, followed by `(` or `?.(` — or `<T>(` when preceded by `new` (generic scan is gated on `new` to avoid `<`/`>` comparison false-positives). Member calls (`obj.BroadcastChannel(...)`), object method shorthands, TypeScript method signatures, and `fn`/`function` declarations named `BroadcastChannel` are excluded. The `:` exclusion is guarded against ternary consequents. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Wrap in `unsafe "<reason>" { new BroadcastChannel(name) }` (or matching call form) to make the cross-context messaging dependency visible in the diff. |
| SYN015 | (0.7+, warning) A fn body accesses `localStorage.*`, `localStorage[key]`, `sessionStorage.*`, or `sessionStorage[key]` — any member or computed access (`.`, `?.`, `[`) on either Web Storage global. Both globals are same-origin storage invisible to botscript's capability model (`reads {}` / `writes {}` labels cover declared resource identifiers, not the Web Storage API). `localStorage` persists across browser sessions and is shared across all tabs on the same origin; `sessionStorage` is per-tab and cleared when the tab closes, but is equally invisible to the model. A fn that accesses either global has undeclared state dependencies invisible to callers and audit tooling. Detection: `localStorage`/`sessionStorage` ident not preceded by `.`/`?.`, followed by `.`, `?.`, or `[`. Fn/function/function* declarations named `localStorage`/`sessionStorage` are excluded. Fn parameters that shadow the global name are excluded (e.g. `fn f(localStorage: Storage)` does not warn); body-local `const`/`let`/`var` bindings are NOT excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Pass a `Storage`-compatible object as an explicit fn parameter so callers control what storage is accessed and tests can inject a mock. If direct global access is required, wrap in `unsafe "reads/writes localStorage for <reason>" { localStorage.getItem(key) }`. |
| SYN016 | (0.7+, warning) A fn body accesses `indexedDB.*` — any member access (`.`, `?.`) on the `indexedDB` global. `indexedDB` is same-origin persistent database storage invisible to botscript's capability model (`reads {}` / `writes {}` labels cover declared resource identifiers, not the Web Storage API). Unlike `localStorage`, `indexedDB` is asynchronous and has no practical size limit, making invisible access higher-impact. A fn that accesses `indexedDB` has undeclared persistent state dependencies invisible to callers and audit tooling. Detection: `indexedDB` ident not preceded by `.`/`?.`, followed by `.` or `?.`. Bare references and `fn`/`function` declarations named `indexedDB` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Pass an `IDBDatabase` handle as an explicit fn parameter so callers control what database is accessed and tests can inject a mock. If direct global access is required, wrap in `unsafe "reads/writes indexedDB for <reason>" { indexedDB.open(name) }`. |
| SYN018 | (0.7+, warning) A fn body calls `Math.random()`, `Math?.random()`, or `Math.random?.()`. `Math.random` generates a random float at runtime but is invisible to botscript's capability model: `uses { random }` covers `random.*` stdlib namespace calls, not the `Math` global. A fn that calls `Math.random()` has an undeclared randomness dependency — callers cannot see it, tests cannot mock or suppress it the way they can the `random` stdlib. Detection: `Math` ident not preceded by `.`/`?.`, followed by `.` or `?.`, member is `random`, followed by `(` or `?.(`. Bare `Math.random` references without a trailing `(` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Replace `Math.random()` with `random.next()` and add `uses { random }` to the fn header. If `Math.random` is required, wrap in `unsafe "uses Math.random for <reason>" { Math.random() }`. |
| SYN022 | (0.7+, warning) A fn body accesses `process.argv`, `process.cwd()`, `process.platform`, `process.arch`, `process.pid`, `process.ppid`, `process.version`, `process.versions`, `process.hrtime()`, `process.uptime()`, `process.memoryUsage()`, `process.cpuUsage()`, or `process.resourceUsage()`. These read ambient Node.js process state at runtime — OS identity, process tree, memory, CPU — invisible to botscript's capability model. Unlike `process.env` (SYN005) and `process.exit` (SYN006), which cover configuration and termination respectively, SYN022 targets the remaining ambient introspection surface. Detection: `process` ident not preceded by `.`/`?.`, followed by `.` or `?.`, member in the ambient-state set, optionally followed by `(` or `?.(`. Bare `process.*` references without a trailing `(` still fire. `obj.process.*` member calls, `unsafe {}` blocks, and `unsafe "reason" fn` bodies are excluded. `process.env` and `process.exit` are handled by SYN005/SYN006 and excluded here. | Pass the required ambient value as an explicit fn parameter so the dependency is visible in the call signature and tests can inject a mock. If direct `process.*` access is required at a bootstrap entry point, wrap in `unsafe "reads process state for <reason>" { process.argv }`. |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ claude mcp add botscript -- npx -y @mbfarias/botscript-mcp
| ----------- | -------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `primer` | (no args) | The canonical language primer (same text the `?primer` directive emits). |
| `transform` | `{ source: string, filename?: string }` | `{ ok: true, code, forms, version, warnings: [...] }` on success, or `{ ok: false, diagnostics: [...] }` on failure. `warnings` is an array of non-blocking diagnostics (e.g. CAP003). |
| `explain` | `{ code: string }` | Long-form explanation for any stable diagnostic code (`ALI001`, `ALI002`, `ALI003`, `BS001`, `BS002`, `CAP001`–`CAP003`, `DEP001`–`DEP004`, `EFF002`–`EFF004`, `FMT001`, `INT001`–`INT005`, `MAT001`–`MAT004`, `RES001`, `RES002`, `SYN001`, `SYN002`, `SYN003`, `SYN004`, `SYN005`, `SYN006`, `SYN007`, `SYN008`, `SYN010`, `SYN011`, `SYN012`, `SYN013`, `SYN014`, `SYN016`, `SYN018`, `SYN022`, `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`, `SYN015`, `SYN016`, `SYN018`, `SYN022`, `THR001`–`THR004`, `UNS001`–`UNS005`, `VER001`–`VER003`) plus a fails/passes example pair. |

A bot's loop becomes deterministic: `transform` → if `ok=false`, read
`diagnostics[0].code` → `explain(code)` → apply `rewrite` → `transform` again.
Expand Down
35 changes: 35 additions & 0 deletions packages/compiler/src/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -876,6 +876,41 @@ const E: Record<string, ErrorCodeEntry> = {
" return bc\n" +
"}",
},
SYN015: {
code: "SYN015",
title: "localStorage / sessionStorage access bypasses the storage capability model",
rule:
"`localStorage.*`, `localStorage[key]`, `sessionStorage.*`, and `sessionStorage[key]` accesses are same-origin storage operations " +
"invisible to botscript's capability model: `reads {}` / `writes {}` labels cover declared resource identifiers, " +
"not the Web Storage API globals. A fn that reads or writes `localStorage`/`sessionStorage` has " +
"undeclared state dependencies — no `reads {}` / `writes {}` declaration in the fn header " +
"covers the access, and callers cannot observe or audit the dependency from the fn's declared surface. " +
"(`localStorage` persists across browser sessions; `sessionStorage` is scoped to the current tab and cleared when the tab closes.)",
idiom:
"pass a storage abstraction (e.g. a `Storage` interface) as an explicit fn parameter so callers " +
"control what storage is accessed, the dependency is visible in the fn signature, and tests can inject a mock; " +
"if direct access is genuinely required, wrap in " +
"`unsafe \"reads/writes storage for <reason>\" { localStorage.getItem(key) }` " +
"(substitute `sessionStorage` and the actual method — e.g. `setItem(key, val)`, `removeItem(key)`, `clear()` — as appropriate)",
rewrite:
"// before — localStorage access invisible to the capability model\n" +
"fn getToken() -> string | null {\n" +
" return localStorage.getItem(\"auth_token\") // SYN015\n" +
"}\n\n" +
"// after — storage passed as a parameter; dependency visible in the signature\n" +
"fn getToken(storage: Storage) -> string | null {\n" +
" return storage.getItem(\"auth_token\")\n" +
"}",
example:
"// SYN015: localStorage access bypasses the storage capability model\n" +
"fn saveUser(user: User) -> void {\n" +
" localStorage.setItem(\"user\", JSON.stringify(user)) // SYN015\n" +
"}\n\n" +
"// fix: pass storage as a parameter\n" +
"fn saveUser(storage: Storage, user: User) -> void {\n" +
" storage.setItem(\"user\", JSON.stringify(user))\n" +
"}",
},
SYN016: {
code: "SYN016",
title: "indexedDB access bypasses the storage capability model",
Expand Down
18 changes: 17 additions & 1 deletion packages/compiler/src/passes/_callgraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,11 +329,17 @@ export function collectFnBodyLocalNames(
tokens: Token[],
fn: FnDecl,
inner: FnDecl[],
opts?: { topLevelOnly?: boolean },
): Set<string> {
const names = new Set<string>();
const open: FnDecl[] = [];
let nextInner = 0;
const start = fn.bodyTokenStart ?? fn.tokenStart;
const topLevelOnly = opts?.topLevelOnly ?? false;
// blockDepth tracks brace nesting for non-function blocks.
// bodyTokenStart points to the function body's opening `{`, so the first `{`
// increments depth to 1; top-level bindings are at depth === 1.
let blockDepth = 0;

for (let i = start; i < fn.tokenEnd; i++) {
while (open.length > 0 && open[open.length - 1]!.tokenEnd <= i) open.pop();
Expand All @@ -344,8 +350,18 @@ export function collectFnBodyLocalNames(
if (open.length > 0) continue;

const tok = tokens[i];
if (!tok || tok.kind !== "ident") continue;
if (!tok) continue;

// Track brace depth for non-function blocks (inner function bodies are already skipped above).
if (tok.kind === "open" && tok.text === "{") { blockDepth++; continue; }
if (tok.kind === "close" && tok.text === "}") { blockDepth--; continue; }

if (tok.kind !== "ident") continue;
if (tok.text !== "const" && tok.text !== "let" && tok.text !== "var") continue;
// When topLevelOnly, skip const/let bindings inside nested blocks (if, for, etc.),
// but keep var — var is function-scoped in JS/TS and shadows the global name for the
// entire function body regardless of where the declaration appears.
if (topLevelOnly && blockDepth > 1 && tok.text !== "var") continue;

const nameIdx = nextSignificant(tokens, i + 1);
const nameTok = tokens[nameIdx];
Expand Down
Loading