Skip to content

Commit 5cb8a99

Browse files
committed
feat(core): add Code Mode checks with static tool scanning to schedules
1 parent c784d17 commit 5cb8a99

16 files changed

Lines changed: 1128 additions & 70 deletions

File tree

‎packages/client/src/promise/generated/types.ts‎

Lines changed: 35 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/codemode/src/codemode.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import { Effect, Schema } from "effect"
2+
import { fromJson } from "./data.js"
23
import type { Extension } from "./extension.js"
34
import { executeProgram } from "./interpreter/execute.js"
45
import { extensionGlobals } from "./interpreter/extensions.js"
56
import { globalNames } from "./interpreter/globals.js"
7+
import { scanTools } from "./scan.js"
68
import { type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js"
79
import type { Tools } from "./tools.js"
810

11+
export { scanTools }
12+
913
/** A tool call admitted during an execution. */
1014
export type {
1115
CallResult,
@@ -64,6 +68,8 @@ export type ExecuteOptions<
6468
> = Options<Provided, Extensions> & {
6569
/** Source for one program in the supported JavaScript subset. */
6670
code: string
71+
/** Read-only JSON values bound in the program's outer global scope (shadowable by top-level declarations). */
72+
bindings?: Readonly<Record<string, DataValue>>
6773
}
6874

6975
/** A JSON value that can cross the confined interpreter boundary. */
@@ -128,7 +134,7 @@ export type Result = typeof Result.Type
128134
/** Reusable confined runtime over explicit tools. */
129135
export type Runtime<R = never> = {
130136
readonly catalog: ReadonlyArray<ToolDescription>
131-
readonly execute: (code: string) => Effect.Effect<Result, never, R>
137+
readonly execute: (code: string, bindings?: Readonly<Record<string, DataValue>>) => Effect.Effect<Result, never, R>
132138
}
133139

134140
const validateLimit = (name: keyof ExecutionLimits, value: number | undefined, minimum: number): number | undefined => {
@@ -150,7 +156,7 @@ export const execute = <
150156
const Extensions extends ReadonlyArray<Extension> = [],
151157
>(
152158
options: ExecuteOptions<Provided, Extensions>,
153-
): Effect.Effect<Result, never, Environment<Provided, Extensions>> => make(options).execute(options.code)
159+
): Effect.Effect<Result, never, Environment<Provided, Extensions>> => make(options).execute(options.code, options.bindings)
154160

155161
/** Creates an Effect-native runtime over explicit, schema-described tools and extensions. */
156162
export const make = <
@@ -175,7 +181,10 @@ export const make = <
175181
get catalog() {
176182
return prepared.catalog
177183
},
178-
execute: (code) =>
179-
executeProgram(code, prepared, limits, options.hooks ?? {}, (ctx) => extensionGlobals(ctx, extensions)),
184+
execute: (code, bindings) =>
185+
executeProgram(code, prepared, limits, options.hooks ?? {}, (ctx) => [
186+
...Object.entries(bindings ?? {}).map(([name, value]) => [name, fromJson(ctx, value)] as const),
187+
...extensionGlobals(ctx, extensions),
188+
]),
180189
}
181190
}

‎packages/codemode/src/interpreter/execute.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export const executeProgram = <R>(
110110
})
111111
}
112112

113-
const parseProgram = (code: string): { ok: true; program: Program } | { ok: false; error: Diagnostic } => {
113+
export const parseProgram = (code: string): { ok: true; program: Program } | { ok: false; error: Diagnostic } => {
114114
try {
115115
const program = parse(code, {
116116
ecmaVersion: "latest",

‎packages/codemode/src/scan.ts‎

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import type { AnyNode, BlockStatement, ModuleDeclaration, Pattern, Program, Statement } from "acorn"
2+
import { Result } from "effect"
3+
import type { Diagnostic } from "./codemode.js"
4+
import { parseProgram } from "./interpreter/execute.js"
5+
import { formatLocation } from "./interpreter/model.js"
6+
7+
/**
8+
* Extracts every `tools.<path>` referenced by a Code Mode program, in first-seen order. Fails on syntax errors,
9+
* dynamic bracket access (`tools[name]`), or aliasing/passing `tools` rather than calling `tools.<path>(...)` directly.
10+
*/
11+
export function scanTools(code: string): Result.Result<ReadonlyArray<string>, Diagnostic> {
12+
if (code.trim().length === 0) {
13+
return Result.fail({ kind: "ParseError", message: "Code cannot be empty." })
14+
}
15+
const parsed = parseProgram(code)
16+
if (!parsed.ok) return Result.fail(parsed.error)
17+
18+
const found = new Set<string>()
19+
const scopes: Array<Set<string>> = [collectScope(parsed.program, true)]
20+
const ancestors: Array<AnyNode> = []
21+
22+
const isShadowed = () => scopes.some((scope) => scope.has("tools"))
23+
24+
const fail = (node: AnyNode, message: string): Diagnostic => {
25+
const loc = node.loc?.start ? { line: node.loc.start.line, column: node.loc.start.column + 1 } : undefined
26+
return {
27+
kind: "UnsupportedSyntax",
28+
message: `${message}${loc ? formatLocation(loc) : ""}`,
29+
...(loc ? { location: loc } : {}),
30+
}
31+
}
32+
33+
const visit = (node: AnyNode | null | undefined): Diagnostic | undefined => {
34+
if (!node) return undefined
35+
36+
if (node.type === "Identifier" && node.name === "tools" && !isShadowed()) {
37+
const parent = ancestors.at(-1)
38+
if (!isReferencePosition(node, parent)) return undefined
39+
const chain = readStaticToolCall(node, ancestors)
40+
if (!chain.ok) return fail(chain.node, chain.reason)
41+
found.add(chain.path)
42+
return undefined
43+
}
44+
45+
const pushedScope = enterNodeScope(node)
46+
if (pushedScope) scopes.push(pushedScope)
47+
ancestors.push(node)
48+
49+
const error = visitChildren(node, visit)
50+
51+
ancestors.pop()
52+
if (pushedScope) scopes.pop()
53+
return error
54+
}
55+
56+
const diagnostic = visit(parsed.program)
57+
if (diagnostic) return Result.fail(diagnostic)
58+
return Result.succeed(Array.from(found))
59+
}
60+
61+
function isReferencePosition(node: AnyNode, parent: AnyNode | undefined): boolean {
62+
if (!parent) return true
63+
if (parent.type === "MemberExpression" && parent.property === node && !parent.computed) return false
64+
if (parent.type === "Property" && parent.key === node && !parent.computed && !parent.shorthand) return false
65+
if (parent.type === "MethodDefinition" && parent.key === node && !parent.computed) return false
66+
if (
67+
(parent.type === "LabeledStatement" || parent.type === "BreakStatement" || parent.type === "ContinueStatement") &&
68+
parent.label === node
69+
) {
70+
return false
71+
}
72+
if (
73+
(parent.type === "FunctionDeclaration" || parent.type === "FunctionExpression" || parent.type === "ClassDeclaration" || parent.type === "ClassExpression") &&
74+
parent.id === node
75+
) {
76+
return false
77+
}
78+
if (parent.type === "VariableDeclarator" && parent.id === node) return false
79+
return true
80+
}
81+
82+
function readStaticToolCall(
83+
root: AnyNode,
84+
ancestors: ReadonlyArray<AnyNode>,
85+
): { readonly ok: true; readonly path: string } | { readonly ok: false; readonly node: AnyNode; readonly reason: string } {
86+
const segments: Array<string> = []
87+
let current = root
88+
let index = ancestors.length - 1
89+
90+
while (index >= 0) {
91+
const parent = ancestors[index]!
92+
if (parent.type === "ChainExpression" && parent.expression === current) {
93+
current = parent
94+
index -= 1
95+
continue
96+
}
97+
if (parent.type !== "MemberExpression" || parent.object !== current) break
98+
if (!parent.computed && parent.property.type === "Identifier") {
99+
segments.push(parent.property.name)
100+
current = parent
101+
index -= 1
102+
continue
103+
}
104+
if (parent.computed && parent.property.type === "Literal" && typeof parent.property.value === "string") {
105+
segments.push(parent.property.value)
106+
current = parent
107+
index -= 1
108+
continue
109+
}
110+
return {
111+
ok: false,
112+
node: parent.property,
113+
reason: "Schedule checks must reference tools statically (e.g. tools.slack.read_thread), not with dynamic bracket expressions.",
114+
}
115+
}
116+
117+
const callParent = index >= 0 ? ancestors[index] : undefined
118+
if (segments.length === 0 || callParent?.type !== "CallExpression" || callParent.callee !== current) {
119+
return {
120+
ok: false,
121+
node: current,
122+
reason: "Schedule checks must call tools directly as tools.<name>(...), not alias or pass the tools object.",
123+
}
124+
}
125+
return { ok: true, path: segments.join(".") }
126+
}
127+
128+
function enterNodeScope(node: AnyNode): Set<string> | undefined {
129+
if (
130+
node.type === "FunctionDeclaration" ||
131+
node.type === "FunctionExpression" ||
132+
node.type === "ArrowFunctionExpression"
133+
) {
134+
const names = new Set<string>()
135+
if (node.id) names.add(node.id.name)
136+
for (const param of node.params) collectPattern(param, names)
137+
if (node.body.type === "BlockStatement") {
138+
for (const name of collectScope(node.body, true)) names.add(name)
139+
}
140+
return names
141+
}
142+
if (node.type === "BlockStatement") return collectScope(node, false)
143+
if (node.type === "CatchClause") {
144+
const names = new Set<string>()
145+
if (node.param) collectPattern(node.param, names)
146+
return names
147+
}
148+
if (node.type === "ForStatement" && node.init?.type === "VariableDeclaration" && node.init.kind !== "var") {
149+
const names = new Set<string>()
150+
for (const decl of node.init.declarations) collectPattern(decl.id, names)
151+
return names
152+
}
153+
if (
154+
(node.type === "ForInStatement" || node.type === "ForOfStatement") &&
155+
node.left.type === "VariableDeclaration" &&
156+
node.left.kind !== "var"
157+
) {
158+
const names = new Set<string>()
159+
for (const decl of node.left.declarations) collectPattern(decl.id, names)
160+
return names
161+
}
162+
return undefined
163+
}
164+
165+
function collectScope(container: Program | BlockStatement, includeVar: boolean): Set<string> {
166+
const names = new Set<string>()
167+
for (const statement of container.body) {
168+
if (statement.type === "FunctionDeclaration" && statement.id) names.add(statement.id.name)
169+
if (statement.type === "ClassDeclaration" && statement.id) names.add(statement.id.name)
170+
if (statement.type === "VariableDeclaration" && (includeVar || statement.kind !== "var")) {
171+
for (const decl of statement.declarations) collectPattern(decl.id, names)
172+
}
173+
}
174+
if (includeVar) collectNestedVars(container.body, names)
175+
return names
176+
}
177+
178+
function collectNestedVars(statements: ReadonlyArray<Statement | ModuleDeclaration>, names: Set<string>) {
179+
const walkStmt = (node: AnyNode | null | undefined) => {
180+
if (!node) return
181+
if (
182+
node.type === "FunctionDeclaration" ||
183+
node.type === "FunctionExpression" ||
184+
node.type === "ArrowFunctionExpression"
185+
) {
186+
return
187+
}
188+
if (node.type === "VariableDeclaration" && node.kind === "var") {
189+
for (const decl of node.declarations) collectPattern(decl.id, names)
190+
}
191+
visitChildren(node, (child) => {
192+
walkStmt(child)
193+
return undefined
194+
})
195+
}
196+
for (const stmt of statements) walkStmt(stmt)
197+
}
198+
199+
function collectPattern(pattern: Pattern, names: Set<string>) {
200+
switch (pattern.type) {
201+
case "Identifier":
202+
names.add(pattern.name)
203+
return
204+
case "AssignmentPattern":
205+
collectPattern(pattern.left, names)
206+
return
207+
case "RestElement":
208+
collectPattern(pattern.argument, names)
209+
return
210+
case "ArrayPattern":
211+
for (const element of pattern.elements) if (element) collectPattern(element, names)
212+
return
213+
case "ObjectPattern":
214+
for (const prop of pattern.properties) {
215+
if (prop.type === "RestElement") collectPattern(prop.argument, names)
216+
if (prop.type === "Property") collectPattern(prop.value, names)
217+
}
218+
return
219+
case "MemberExpression":
220+
return
221+
}
222+
}
223+
224+
function visitChildren(
225+
node: AnyNode,
226+
visit: (child: AnyNode | null | undefined) => Diagnostic | undefined,
227+
): Diagnostic | undefined {
228+
for (const [key, value] of Object.entries(node as unknown as Record<string, unknown>)) {
229+
if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue
230+
if (Array.isArray(value)) {
231+
for (const item of value) {
232+
if (isAstNode(item)) {
233+
const error = visit(item)
234+
if (error) return error
235+
}
236+
}
237+
continue
238+
}
239+
if (isAstNode(value)) {
240+
const error = visit(value)
241+
if (error) return error
242+
}
243+
}
244+
return undefined
245+
}
246+
247+
function isAstNode(value: unknown): value is AnyNode {
248+
return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string"
249+
}

0 commit comments

Comments
 (0)