Skip to content
Merged
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 @@ -198,7 +198,8 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope.
| SYN001 | Duplicate fn header clause (e.g. two `reads { }` on the same fn, or two `intent:`, or two `throws {}`), or a label inside `reads {}` / `writes {}` / `throws {}` that is not a plain identifier. `parseFn` is version-agnostic, so SYN001 fires whenever a duplicate clause is written regardless of the `?bs` pin. | Declare each header clause once; merge label lists rather than repeating the clause; use bare identifiers (not quoted strings) as labels. |
| SYN002 | (0.7+, warning) A fn body contains a native `throw` statement. Native throws bypass botscript's Result-based error contract: callers using `?` unwrap or `match` on Result will not observe exceptions raised via `throw`. | Replace `throw new ErrorType(...)` with `return err(new ErrorType(...))` and update the return type to `Result<T, ErrorType>`. |
| 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" { }`. |
| 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 "<reason>" { 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) }`. |
| 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. |
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`, `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`–`DEP004`, `EFF002`–`EFF004`, `FMT001`, `INT001`–`INT005`, `MAT001`–`MAT004`, `RES001`, `RES002`, `SYN001`, `SYN002`, `SYN003`, `SYN004`, `SYN005`, `SYN006`, `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
31 changes: 31 additions & 0 deletions packages/compiler/src/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,37 @@ const E: Record<string, ErrorCodeEntry> = {
" unsafe \"stdout.write returns void\" { stdout.write(`Hello, ${name}`) }\n" +
"}",
},
SYN004: {
code: "SYN004",
title: "eval() or Function() / new Function() calls bypass all static capability and syntax checks",
rule:
"`eval(...)`, `Function(...)`, and `new Function(...)` execute strings as code at runtime — " +
"no static analysis can see what they do; every capability check (CAP001/CAP002), " +
"resource declaration (reads/writes), and safety check (SYN002/SYN003) can be bypassed " +
"by routing the unsafe pattern through eval or the Function constructor",
idiom:
"refactor eval-based patterns to use explicit code paths or config parameters; " +
"if eval is unavoidable (e.g. a sandboxed interpreter or intentional scripting surface), " +
"wrap in `unsafe \"<reason>\" { eval(...) }` to make the escape hatch visible in the diff",
rewrite:
"// before — eval hides config key access from static analysis\n" +
"fn getConfig(key: string) -> string {\n" +
" return eval('process.env.' + key) // SYN004\n" +
"}\n\n" +
"// after — explicit parameter, no eval\n" +
"fn getConfig(value: string) -> string {\n" +
" return value\n" +
"}",
example:
"// SYN004: eval bypasses all static checks\n" +
"fn run(code: string) -> string {\n" +
" return eval(code)\n" +
"}\n\n" +
"// fix: suppress with unsafe if eval is genuinely needed\n" +
"fn run(code: string) -> string {\n" +
' return unsafe "evaluates user-provided script in sandbox" { eval(code) }\n' +
"}",
},
SYN005: {
code: "SYN005",
title: "process.env access is an undeclared deployment environment dependency",
Expand Down
183 changes: 181 additions & 2 deletions packages/compiler/src/passes/syn-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,11 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult
const warnings: Diagnostic[] = [];
const syn002 = getErrorCode("SYN002")!;
const syn003 = getErrorCode("SYN003")!;
const syn004 = getErrorCode("SYN004")!;
const syn005 = getErrorCode("SYN005")!;
const syn006 = getErrorCode("SYN006")!;

// Collect char-offset ranges where SYN002/SYN003/SYN005/SYN006 are suppressed:
// Collect char-offset ranges where SYN002/SYN003/SYN004/SYN005/SYN006 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).
Expand All @@ -70,7 +71,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 — skip SYN002/SYN003/SYN004/SYN005.
// 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;
Expand Down Expand Up @@ -265,6 +266,184 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult
});
}

// SYN004: eval() and Function() / new Function() call detection.
// Fires on:
// eval(...) — global eval not preceded by `.`/`?.`, followed by `(`
// Function(…) — bare Function call not preceded by `.`/`?.`, followed by `(`
// new Function(…) — same as bare Function call, `new` prefix only affects message
Comment on lines +269 to +273
// Suppressed inside `unsafe { }` blocks and `unsafe fn` bodies.
// `.eval(...)` (method call on a local) and `Function.*` member accesses are NOT flagged.
let nextInner4 = 0;
const open4: typeof inner = [];
for (let i = bodyStart; i < decl.tokenEnd; i++) {
while (open4.length > 0 && open4[open4.length - 1]!.tokenEnd <= i) open4.pop();
while (nextInner4 < inner.length && inner[nextInner4]!.tokenStart <= i) {
open4.push(inner[nextInner4]!);
nextInner4++;
}
if (open4.length > 0) continue;

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

// --- eval(...) detection ---
if (tok4.text === "eval") {
// Exclude: `obj.eval(...)` — preceded by `.` or `?.`
const prevIdx4 = prevSignificant(tokens, i - 1);
const prev4 = tokens[prevIdx4];
if (prev4 && ((prev4.kind === "punct" && prev4.text === ".") || prev4.kind === "questionDot"))
continue;

// Must be followed by `(` (direct call), `?.(` (optional call), or `<T>(` (TS instantiation).
const nextIdx4 = nextSignificant(tokens, i + 1);
const next4 = tokens[nextIdx4];
let isOptEval = false;
let callIdx4 = nextIdx4;
if (next4 && next4.kind === "questionDot") {
isOptEval = true;
callIdx4 = nextSignificant(tokens, nextIdx4 + 1);
} else if (next4 && next4.kind === "operator" && next4.text === "<") {
// TypeScript instantiation form: eval<T>(...)
let depth = 1;
let j = nextIdx4 + 1;
while (j < tokens.length && depth > 0) {
const t = tokens[j];
if (!t) break;
if (t.kind === "operator" && t.text === "<") depth++;
else if (t.kind === "operator" && (t.text === ">" || t.text === ">>" || t.text === ">>>"))
depth -= t.text.length;
j++;
}
const afterGenericIdx4 = nextSignificant(tokens, j);
const afterGeneric4 = tokens[afterGenericIdx4];
if (afterGeneric4 && afterGeneric4.kind === "open" && afterGeneric4.text === "(")
callIdx4 = afterGenericIdx4;
}
Comment on lines +305 to +321
Comment on lines +305 to +321
const callTok4 = tokens[callIdx4];
if (!callTok4 || !(callTok4.kind === "open" && callTok4.text === "(")) continue;

// Exclude declarations: `function eval(params) {}`, `{ eval(params) {} }`, and
// return-type-annotated forms `function eval(params): T {}` / `{ eval(params): T {} }`.
// The `:` check is guarded: in a ternary (`? eval(x) : y`), the token before `eval`
// is `?` (question) — that is a call, not a declaration, so `:` is skipped there.
if (callTok4.matchedAt !== undefined) {
const afterCloseIdx = nextSignificant(tokens, callTok4.matchedAt + 1);
const afterClose = tokens[afterCloseIdx];
const isTernaryConsequent = prev4 && prev4.kind === "question";
if (afterClose && (
(afterClose.kind === "open" && afterClose.text === "{") ||
afterClose.kind === "fatArrow" ||
(!isTernaryConsequent && afterClose.kind === "punct" && afterClose.text === ":")
)) continue;
}

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

const callSep4 = isOptEval ? "?." : "";
const loc4 = locationOf(src, tok4.start);
warnings.push({
code: "SYN004",
severity: "warning",
file: null,
line: loc4.line,
column: loc4.column,
start: tok4.start,
end: callTok4.start + 1,
message:
`fn '${decl.name}' calls eval${callSep4}() — ` +
`eval executes a string as code and bypasses all static capability, ` +
`resource, and safety checks; refactor to explicit code or wrap in unsafe "reason" { eval(src) }`,
Comment on lines +352 to +355
rule: syn004.rule,
idiom: syn004.idiom,
rewrite: syn004.rewrite,
});
continue;
}

// --- new Function(...) / Function(...) detection ---
// Both `new Function(body)` and bare `Function(body)` execute a string as
// code at runtime and are equivalent bypasses.
if (tok4.text === "Function") {
Comment thread
marcelofarias marked this conversation as resolved.
const prevIdx4 = prevSignificant(tokens, i - 1);
const prev4 = tokens[prevIdx4];

// Exclude: `obj.Function(...)` — preceded by `.` or `?.`
if (prev4 && ((prev4.kind === "punct" && prev4.text === ".") || prev4.kind === "questionDot"))
continue;

// Exclude: `Function.prototype.*` — followed by `.` (member access, not a call)
// Must be followed by `(` (direct call), `?.(` (optional call), or `<T>(` (TS instantiation).
const nextIdx4 = nextSignificant(tokens, i + 1);
const next4 = tokens[nextIdx4];
let isOptFunc = false;
let callIdx4 = nextIdx4;
if (next4 && next4.kind === "questionDot") {
isOptFunc = true;
callIdx4 = nextSignificant(tokens, nextIdx4 + 1);
} else if (next4 && next4.kind === "operator" && next4.text === "<") {
// TypeScript instantiation form: Function<T>(...) / new Function<T>(...)
let depth = 1;
let j = nextIdx4 + 1;
while (j < tokens.length && depth > 0) {
const t = tokens[j];
if (!t) break;
if (t.kind === "operator" && t.text === "<") depth++;
else if (t.kind === "operator" && (t.text === ">" || t.text === ">>" || t.text === ">>>"))
depth -= t.text.length;
j++;
}
const afterGenericIdx4 = nextSignificant(tokens, j);
const afterGeneric4 = tokens[afterGenericIdx4];
if (afterGeneric4 && afterGeneric4.kind === "open" && afterGeneric4.text === "(")
callIdx4 = afterGenericIdx4;
}
Comment on lines +383 to +399
const callTok4 = tokens[callIdx4];
if (!callTok4 || !(callTok4.kind === "open" && callTok4.text === "(")) continue;

// Exclude declarations: `function Function(params) {}`, method shorthands, and
// return-type-annotated forms `function Function(params): T {}` / `{ Function(params): T {} }`.
// Guard `:` against ternary then-branch: `? Function(body) :` has `?` before Function.
// `? new Function(body) :` has `new` before Function but `?` before `new` — check both.
if (callTok4.matchedAt !== undefined) {
const afterCloseIdx = nextSignificant(tokens, callTok4.matchedAt + 1);
const afterClose = tokens[afterCloseIdx];
const prevBeforeNew4 = (prev4 && prev4.kind === "ident" && prev4.text === "new")
? tokens[prevSignificant(tokens, prevIdx4 - 1)]
: undefined;
const isTernaryConsequent4 = (prev4 && prev4.kind === "question") ||
(prevBeforeNew4 !== undefined && prevBeforeNew4 !== null && prevBeforeNew4.kind === "question");
if (afterClose && (
(afterClose.kind === "open" && afterClose.text === "{") ||
afterClose.kind === "fatArrow" ||
(!isTernaryConsequent4 && afterClose.kind === "punct" && afterClose.text === ":")
)) continue;
}

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

const hasNew = prev4 && prev4.kind === "ident" && prev4.text === "new";
const funcCallSep = isOptFunc ? "?." : "";
const warnStart = hasNew ? prev4!.start : tok4.start;
const loc4 = locationOf(src, warnStart);
warnings.push({
code: "SYN004",
severity: "warning",
file: null,
line: loc4.line,
column: loc4.column,
start: warnStart,
end: callTok4.start + 1,
message:
`fn '${decl.name}' constructs ${hasNew ? "new " : ""}Function${funcCallSep}() — ` +
`the Function constructor executes a string as code and bypasses all static checks; ` +
`refactor to explicit code or wrap in unsafe "reason" { ${hasNew ? "new Function(body)" : "Function(body)"} }`,
Comment on lines +436 to +439
rule: syn004.rule,
idiom: syn004.idiom,
rewrite: syn004.rewrite,
});
}
}

// SYN005: process.env access detection.
// Fires on any `process.env` access (read or write) — not just calls.
// Detection: `process` not preceded by `.`/`?.`, followed by `.`/`?.` then `env`.
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", "SYN005", "SYN006",
"SYN001", "SYN002", "SYN003", "SYN004", "SYN005", "SYN006",
"THR001", "THR002", "THR003", "THR004",
"UNS001", "UNS002", "UNS003", "UNS004", "UNS005",
"VER001", "VER002", "VER003",
Expand Down
Loading
Loading