Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,8 @@ 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. |
| 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) }`. |
| SYN015 | (0.7+, warning) A fn body accesses `localStorage.*` or `sessionStorage.*` β€” any member access (`.`, `?.`) on either Web Storage global. Both are synchronous same-origin key/value stores invisible to botscript's capability model (`reads {}` / `writes {}` labels cover declared resource identifiers, not the Web Storage API). A fn that accesses either store has undeclared persistent (`localStorage`) or session-scoped (`sessionStorage`) state dependencies invisible to callers and audit tooling. Detection: `localStorage` or `sessionStorage` ident not preceded by `.`/`?.`, followed by `.` or `?.`. Bare references and `fn`/`function`/`function*` declarations named `localStorage`/`sessionStorage` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Pass the storage value as an explicit fn parameter so callers control what is read and tests can inject a mock. If direct access is required, wrap in `unsafe "accesses 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` (SYN015), `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() }`. |
| SYN019 | (0.7+, warning) A fn body calls `crypto.getRandomValues(buf)` or `crypto.randomUUID()` (including optional-chain forms `crypto?.getRandomValues(buf)` and optional-call forms `crypto.getRandomValues?.(buf)`). These calls generate cryptographic randomness at runtime but are invisible to botscript's capability model: `uses { random }` covers `random.*` stdlib calls (`random.next()`, `random.int()`), not the `crypto` global. A fn that calls these methods has an undeclared randomness dependency β€” tests cannot control the output and callers cannot observe the dependency from the fn header. Detection: `crypto` ident not preceded by `.`/`?.`, followed by `.` or `?.`, followed by `getRandomValues` or `randomUUID`, followed by `(` or `?.(`. Member calls (`obj.crypto.getRandomValues(...)`), non-randomness members (e.g. `crypto.subtle.digest(...)`), bare references, and `fn`/`function` declarations named `crypto` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Use `random.next()` or `random.int(min, max)` from the `random` stdlib with `uses { random }` when general randomness is sufficient. If cryptographic randomness or UUIDs are genuinely required, wrap in `unsafe "uses crypto for <reason>" { crypto.getRandomValues(buf) }`. |
| SYN022 | (0.7+, warning) A fn body accesses `process.argv`, `process.cwd()`, `process.platform`, `process.arch`, `process.pid`, `process.ppid`, `process.version`, `process.versions`, `process.hrtime()`, `process.uptime()`, `process.memoryUsage()`, `process.cpuUsage()`, or `process.resourceUsage()`. These read ambient Node.js process state at runtime β€” OS identity, process tree, memory, CPU β€” invisible to botscript's capability model. Unlike `process.env` (SYN005) and `process.exit` (SYN006), which cover configuration and termination respectively, SYN022 targets the remaining ambient introspection surface. Detection: `process` ident not preceded by `.`/`?.`, followed by `.` or `?.`, member in the ambient-state set, optionally followed by `(` or `?.(`. Bare `process.*` references without a trailing `(` still fire. `obj.process.*` member calls, `unsafe {}` blocks, and `unsafe "reason" fn` bodies are excluded. `process.env` and `process.exit` are handled by SYN005/SYN006 and excluded here. | Pass the required ambient value as an explicit fn parameter so the dependency is visible in the call signature and tests can inject a mock. If direct `process.*` access is required at a bootstrap entry point, wrap in `unsafe "reads process state for <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`, `SYN019`, `SYN022`, `SYN023`, `THR001`–`THR004`, `UNS001`–`UNS005`, `VER001`–`VER003`) plus a fails/passes example pair. |
| `explain` | `{ code: string }` | Long-form explanation for any stable diagnostic code (`ALI001`, `ALI002`, `ALI003`, `BS001`, `BS002`, `CAP001`–`CAP003`, `DEP001`–`DEP004`, `EFF002`–`EFF004`, `FMT001`, `INT001`–`INT005`, `MAT001`–`MAT004`, `RES001`, `RES002`, `SYN001`, `SYN002`, `SYN003`, `SYN004`, `SYN005`, `SYN006`, `SYN007`, `SYN008`, `SYN010`, `SYN011`, `SYN012`, `SYN013`, `SYN014`, `SYN015`, `SYN016`, `SYN018`, `SYN019`, `SYN022`, `SYN023`, `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
33 changes: 33 additions & 0 deletions packages/compiler/src/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -876,6 +876,39 @@ const E: Record<string, ErrorCodeEntry> = {
" return bc\n" +
"}",
},
SYN015: {
code: "SYN015",
title: "localStorage / sessionStorage access bypasses the storage capability model",
rule:
"`localStorage.*` and `sessionStorage.*` accesses are synchronous same-origin Web Storage " +
"operations invisible to botscript's capability model: `reads {}` / `writes {}` labels cover " +
"declared resource identifiers, not the Web Storage API globals. A fn that accesses either " +
"store has undeclared persistent (`localStorage`) or session-scoped (`sessionStorage`) state " +
"dependencies β€” no header declaration covers the access, and callers cannot observe or audit " +
"the dependency from the fn's declared surface.",
idiom:
"pass the storage value as an explicit fn parameter so callers control what is read and tests " +
"can inject a mock; if direct Web Storage access is genuinely required, wrap in " +
"`unsafe \"accesses localStorage for <reason>\" { localStorage.getItem(key) }`",
rewrite:
"// before β€” localStorage access invisible to the capability model\n" +
"fn getTheme() -> string {\n" +
" return localStorage.getItem('theme') ?? 'light' // SYN015\n" +
"}\n\n" +
"// after β€” value passed as parameter; dependency visible in the signature\n" +
"fn getTheme(storedTheme: string | null) -> string {\n" +
" return storedTheme ?? 'light'\n" +
"}",
example:
"// SYN015: localStorage access invisible to capability model\n" +
"fn saveUser(user: User) -> void {\n" +
" localStorage.setItem('user', JSON.stringify(user)) // SYN015\n" +
"}\n\n" +
"// fix: wrap in unsafe with a justification\n" +
"fn saveUser(user: User) -> void {\n" +
" unsafe \"persists user to localStorage\" { localStorage.setItem('user', JSON.stringify(user)) }\n" +
"}",
},
SYN016: {
code: "SYN016",
title: "indexedDB access bypasses the storage capability model",
Expand Down
58 changes: 58 additions & 0 deletions packages/compiler/src/passes/syn-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,17 @@
* named `BroadcastChannel`, object/class method shorthands, and TypeScript method
* signatures. The `:` exclusion is guarded against ternary consequents.
*
* SYN015 A `localStorage.*` or `sessionStorage.*` access was detected in a fn body (?bs 0.7+).
* Both are same-origin synchronous key/value stores 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 either
* store has undeclared persistent (`localStorage`) or session-scoped (`sessionStorage`)
* state dependencies that callers cannot observe or audit from the fn's declared surface.
* Detection: `localStorage` or `sessionStorage` ident not preceded by `.`/`?.`,
* followed by `.` or `?.`. `fn`/`function`/`function*` declarations named
* `localStorage`/`sessionStorage` and bare references are excluded.
* `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed.
*
* SYN016 An `indexedDB.*` access was detected in a fn body (?bs 0.7+).
* `indexedDB` is same-origin persistent database storage invisible to botscript's
* capability model: `reads {}` / `writes {}` labels cover declared resource
Expand Down Expand Up @@ -239,6 +250,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult
const syn012 = getErrorCode("SYN012")!;
const syn013 = getErrorCode("SYN013")!;
const syn014 = getErrorCode("SYN014")!;
const syn015 = getErrorCode("SYN015")!;
const syn016 = getErrorCode("SYN016")!;
const syn018 = getErrorCode("SYN018")!;
const syn019 = getErrorCode("SYN019")!;
Expand Down Expand Up @@ -1284,6 +1296,52 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult
break;
}

// ── SYN015: localStorage.* / sessionStorage.* access ─────────────────
case "localStorage":
case "sessionStorage": {
// Exclude: `obj.localStorage` β€” preceded by `.` or `?.`
const prevIdx15 = prevSignificant(tokens, i - 1);
const prev15 = tokens[prevIdx15];
if (prev15 && ((prev15.kind === "punct" && prev15.text === ".") || prev15.kind === "questionDot"))
continue;

// Exclude: fn/function/function* declarations named localStorage or sessionStorage
if (prev15 && prev15.kind === "keyword" && prev15.text === "fn") continue;
if (prev15 && prev15.kind === "ident" && prev15.text === "function") continue;
if (isFunctionStarDecl(tokens, prevIdx15)) continue;

// Must be followed by `.` or `?.` β€” bare references (e.g. passing `localStorage` as a value) are not flagged
const nextIdx15 = nextSignificant(tokens, i + 1);
const next15 = tokens[nextIdx15];
const isDot15 = next15 && next15.kind === "punct" && next15.text === ".";
const isOptChain15 = next15 && next15.kind === "questionDot";
if (!isDot15 && !isOptChain15) continue;

if (isInsideRange(tok.start, unsafeRanges)) continue;

const sep15 = isOptChain15 ? "?." : ".";
const loc15 = locationOf(src, tok.start);
const ns15 = tok.text;
warnings.push({
code: "SYN015",
severity: "warning",
file: null,
line: loc15.line,
column: loc15.column,
start: tok.start,
end: next15!.end,
message:
`fn '${decl.name}' accesses ${ns15}${sep15} β€” ` +
`${ns15} is synchronous same-origin Web Storage invisible to the capability model; ` +
`no reads {} / writes {} label covers it; ` +
`pass the storage value as a parameter or wrap in unsafe "accesses ${ns15} for <reason>" { ${ns15}${sep15}getItem(key) }`,
rule: syn015.rule,
idiom: syn015.idiom,
rewrite: syn015.rewrite,
});
break;
}

// ── SYN016: indexedDB.* access ───────────────────────────────────────
case "indexedDB": {
// Exclude: `obj.indexedDB` β€” preceded by `.` or `?.`
Expand Down
2 changes: 1 addition & 1 deletion packages/compiler/tests/error-codes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ describe("error-code registry", () => {
"INT001", "INT002", "INT003", "INT004", "INT005",
"MAT001", "MAT002", "MAT003", "MAT004",
"RES001", "RES002",
"SYN001", "SYN002", "SYN003", "SYN004", "SYN005", "SYN006", "SYN007", "SYN008", "SYN010", "SYN011", "SYN012", "SYN013", "SYN014", "SYN016", "SYN018", "SYN019", "SYN022", "SYN023",
"SYN001", "SYN002", "SYN003", "SYN004", "SYN005", "SYN006", "SYN007", "SYN008", "SYN010", "SYN011", "SYN012", "SYN013", "SYN014", "SYN015", "SYN016", "SYN018", "SYN019", "SYN022", "SYN023",
"THR001", "THR002", "THR003", "THR004",
"UNS001", "UNS002", "UNS003", "UNS004", "UNS005",
"VER001", "VER002", "VER003",
Expand Down
Loading
Loading