Skip to content
Open
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
57 changes: 37 additions & 20 deletions packages/codemode/src/interpreter/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,34 +137,49 @@ export const extensionGlobals = <R>(
// A host function as a program function. Arguments cross in; a global's call runs inside the host's extension
// hooks, which see the host's own error on failure; then whatever came back, or was thrown, crosses out so the
// program catches a copy. Functions inside results are part of a value's API and skip the hooks.
// The before hook runs before the host function is called, so a call it refuses never reaches the host.
const wrap = (value: Function, label: string, describe?: (args: ReadonlyArray<unknown>) => ExtensionInvocation) =>
fn(builtins, value.name, value.length, (_, values, site) => {
const args = values.map((item, index) => toHost(item, `Argument ${index + 1} to ${label}`))
const hooks = ctx.tools.hooks
const call = describe?.(args)
const runner = ctx.runner
const toProgram = Effect.mapError((reason: unknown) => new Throw(fromHost(reason, label)))
const before =
call === undefined || hooks["extension.before"] === undefined
? undefined
: hooks["extension.before"](call).pipe(toProgram)
const settle = (run: Effect.Effect<unknown, unknown, R>) =>
(describe === undefined
? run
: hooked(describe(args), hooks["extension.before"], hooks["extension.after"], run)
).pipe(
Effect.mapError((reason) => new Throw(fromHost(reason, label))),
(call === undefined ? run : hooked(call, undefined, hooks["extension.after"], run)).pipe(
toProgram,
Effect.map((settled) => fromHost(settled, label)),
)
let result: unknown
try {
result = value.apply(undefined, args)
} catch (reason) {
return blocking(ctx.runner.blocking(settle(Effect.fail(reason))))
const later = (run: Effect.Effect<Value, unknown, R>) => {
const promise = runner.newPromise()
const resolving = runner.resolving(promise, site)
runner.host(
run,
(settled) => (settled.ok ? resolving.resolve(settled.value as Value) : resolving.reject(settled.error)),
site,
)
return promise
}
if (!(result instanceof Promise)) return blocking(ctx.runner.blocking(settle(Effect.succeed(result))))
const runner = ctx.runner
const promise = runner.newPromise()
const resolving = runner.resolving(promise, site)
runner.host(
settle(Effect.tryPromise({ try: () => result, catch: (reason) => reason })),
(settled) => (settled.ok ? resolving.resolve(settled.value as Value) : resolving.reject(settled.error)),
site,
)
return promise
// A refused async function rejects its promise, as its own failure would.
if (value instanceof AsyncFunction) {
const run = settle(Effect.tryPromise({ try: () => value.apply(undefined, args), catch: (reason) => reason }))
return later(before === undefined ? run : Effect.andThen(before, run))
}
return (function* () {
if (before !== undefined) yield runner.blocking(before)
let result: unknown
try {
result = value.apply(undefined, args)
} catch (reason) {
return yield* blocking(runner.blocking(settle(Effect.fail(reason))))
}
if (!(result instanceof Promise)) return yield* blocking(runner.blocking(settle(Effect.succeed(result))))
return later(settle(Effect.tryPromise({ try: () => result, catch: (reason) => reason })))
})()
})

return extensions.flatMap((extension) =>
Expand Down Expand Up @@ -195,6 +210,8 @@ const crossing = <T>(convert: () => T): T | typeof left => {
}
}

const AsyncFunction = (async () => {}).constructor

const hostErrors = new Map<string, ErrorConstructor>([
["TypeError", TypeError],
["RangeError", RangeError],
Expand Down
23 changes: 22 additions & 1 deletion packages/codemode/test/codemode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ describe("CodeMode call hooks", () => {
})

test("a failing before hook denies the call; the program catches the host's error", async () => {
const reached: Array<string> = []
const runtime = CodeMode.make({
tools: {
lookup: Tool.make({
Expand All @@ -248,7 +249,21 @@ describe("CodeMode call hooks", () => {
execute: () => Effect.succeed("ok"),
}),
},
extensions: [Extension.make({ name: "web", globals: { fetch: async (url: string) => url } })],
extensions: [
Extension.make({
name: "web",
globals: {
fetch: async (url: string) => {
reached.push(url)
return url
},
read: () => {
reached.push("read")
return "read"
},
},
}),
],
hooks: { "extension.before": (call) => Effect.fail(new Error(`${call.name} is not allowed`)) },
})
expect(
Expand All @@ -263,6 +278,12 @@ describe("CodeMode call hooks", () => {
ok: false,
error: { kind: "ExecutionFailure", message: "Error: fetch is not allowed" },
})
expect(await Effect.runPromise(runtime.execute(`try { read() } catch (e) { return e.message }`))).toMatchObject({
ok: true,
value: "read is not allowed",
})
// A denied call never runs the host function.
expect(reached).toEqual([])
const refused = CodeMode.make({
tools: {
lookup: Tool.make({
Expand Down
19 changes: 15 additions & 4 deletions packages/core/src/codemode/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ const description = [
export const create = (
inventory: Inventory,
executeTool: (name: string, tool: Info, input: unknown, context: ToolContext) => Effect.Effect<Result, Error>,
// Runs before each `fetch` the program makes; a failure refuses that fetch and throws inside the program.
checkFetch: (request: ReturnType<typeof CodeModeWeb.display>, context: ToolContext) => Effect.Effect<void, unknown>,
) => {
return {
name: "execute",
Expand Down Expand Up @@ -115,7 +117,7 @@ export const create = (
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
return text === "" ? null : text
}),
hooks: progressHooks(record),
hooks: progressHooks(record, (request) => checkFetch(request, context)),
}),
)
const toolCalls = yield* Ref.get(calls)
Expand Down Expand Up @@ -152,7 +154,10 @@ export const create = (
}

// Rows appear in start order; the same call object arrives at both hooks, so a call finds its row again.
function progressHooks(record: (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) => Effect.Effect<unknown>) {
function progressHooks(
record: (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) => Effect.Effect<unknown>,
checkFetch: (request: ReturnType<typeof CodeModeWeb.display>) => Effect.Effect<void, unknown>,
) {
const rows = new WeakMap<object, number>()
const start = (call: object, entry: ExecuteCall) =>
record((items) => {
Expand All @@ -177,8 +182,14 @@ function progressHooks(record: (update: (items: Array<ExecuteCall>) => Array<Exe
// Only listed extension functions get a row; anything else stays out of the TUI.
"extension.before": (call) => {
switch (call.name) {
case "fetch":
return start(call, { tool: call.name, status: "running", input: CodeModeWeb.display(call.args) })
case "fetch": {
const request = CodeModeWeb.display(call.args)
// A failing before hook skips the after hook, so a refused fetch settles its row here.
return start(call, { tool: call.name, status: "running", input: request }).pipe(
Effect.andThen(checkFetch(request)),
Effect.tapError((error) => settle(call, { status: "failure", error })),
)
}
default:
return Effect.void
}
Expand Down
23 changes: 18 additions & 5 deletions packages/core/src/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const layer = Layer.effect(
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const image = yield* Image.Service
const permission = yield* Permission.Service

type NormalizedItem = Tool.Content | "decode" | "size"
const normalizeImages = Effect.fnUntraced(function* (content: ReadonlyArray<Tool.Content>) {
Expand Down Expand Up @@ -268,10 +269,22 @@ const layer = Layer.effect(
const cached = catalog?.data === data && catalog.names === names ? catalog : undefined
const codeModeInventory = cached?.inventory ?? { tools: codeModeTools, namespaces: data.namespaces }
const codeModeTool = codeModeEnabled
? CodeModeTool.create(codeModeInventory, (name, tool, input, context) =>
beforeExecute(name, input, context).pipe(
Effect.flatMap((event) => executeTool(tool, name, event.input, context)),
),
? CodeModeTool.create(
codeModeInventory,
(name, tool, input, context) =>
beforeExecute(name, input, context).pipe(
Effect.flatMap((event) => executeTool(tool, name, event.input, context)),
),
// Applies the webfetch rules and policies to Code Mode fetches, without asking.
(request, context) =>
permission.assertNotDenied({
action: "webfetch",
resources: [request.url],
metadata: request,
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
}),
)
: undefined
const codeModeCatalog = !codeModeEnabled
Expand Down Expand Up @@ -362,5 +375,5 @@ function namespaceError(name: string) {
export const node = makeLocationNode({
service: Service,
layer,
deps: [PluginHooks.node, Image.node],
deps: [PluginHooks.node, Image.node, Permission.node],
})
98 changes: 95 additions & 3 deletions packages/core/test/tool-execute.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { expect, test } from "bun:test"
import { CodeModeTool } from "@opencode/core/codemode/tool"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { Image } from "@opencode/core/image"
import { Permission } from "@opencode/core/permission"
import { Tool } from "@opencode/core/tool"
import { execute } from "@opencode/core/tool/runtime"
import { Agent } from "@opencode/schema/agent"
import { Session } from "@opencode/schema/session"
import { SessionMessage } from "@opencode/schema/session-message"
import type { Info } from "@opencode/schema/tool"
import { Effect, Schema } from "effect"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { permissionLayer } from "./lib/permission"
import { executeTool, toolIdentity } from "./lib/tool"

const context = {
sessionID: Session.ID.make("ses_execute"),
Expand All @@ -17,7 +24,31 @@ const context = {
}

const createCodeMode = (tools: ReadonlyMap<string, Info>) =>
CodeModeTool.create({ tools }, (_, tool, input, context) => execute(tool, input, context))
CodeModeTool.create(
{ tools },
(_, tool, input, context) => execute(tool, input, context),
() => Effect.void,
)

const checks: Permission.AssertInput[] = []
let rules: Permission.Ruleset = []
const registry = testEffect(
AppNodeBuilder.build(Tool.node, [
Permission.node.replace(
permissionLayer({
assertNotDenied: (input) =>
Effect.sync(() => checks.push(input)).pipe(
Effect.andThen(
input.resources.some((resource) => Permission.evaluate(input.action, resource, rules).effect === "deny")
? new Permission.BlockedError({ rules, permission: input.action, resources: input.resources })
: Effect.void,
),
),
}),
),
Image.node.replace(imagePassthrough),
]),
)

test("execute describes invariant Code Mode behavior", () => {
expect(createCodeMode(new Map()).description).toBe(
Expand All @@ -41,6 +72,59 @@ test("execute rejects filesystem fetches", async () => {
expect(result.output.output).toContain("fetch cannot be used for filesystem access.")
})

registry.live("execute applies the webfetch rules to each fetch and throws when one is denied", () =>
Effect.gen(function* () {
const reached: Array<string> = []
const server = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.serve({
port: 0,
fetch: (request) => {
reached.push(new URL(request.url).pathname)
return new Response("fetched")
},
}),
),
(server) => Effect.promise(() => server.stop(true)),
)
const allowed = new URL("/allowed", server.url).href
const denied = new URL("/denied", server.url).href
checks.length = 0
rules = [{ action: "webfetch", resource: denied, effect: "deny" }]
const tools = yield* Tool.Service
const run = (url: string) =>
executeTool(tools, {
sessionID: context.sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: `call_${url}`,
name: "execute",
input: { code: `return await (await fetch(${JSON.stringify(url)}, { method: "post" })).text()` },
},
})

const ok = yield* run(allowed)
expect(ok.output.output).toBe("fetched")
expect(ok.metadata).toEqual({
toolCalls: [{ tool: "fetch", status: "completed", input: { method: "POST", url: allowed } }],
})

const blocked = yield* run(denied)
expect(blocked.output.error).toBe(true)
expect(blocked.output.output).toContain("Permission denied: webfetch")
expect(blocked.metadata).toEqual({
error: true,
toolCalls: [{ tool: "fetch", status: "error", input: { method: "POST", url: denied } }],
})
expect(checks.map((input) => ({ action: input.action, resources: input.resources }))).toEqual([
{ action: "webfetch", resources: [allowed] },
{ action: "webfetch", resources: [denied] },
])
expect(reached).toEqual(["/allowed"])
}),
)

test("canonical execution distinguishes declared, model-only, and raw schema outputs", async () => {
const declared: Info = {
name: "declared",
Expand Down Expand Up @@ -177,10 +261,18 @@ test("executions share the prepared runtime of an inventory while keeping per-ca
execute: (input, context) => Effect.succeed({ content: `${context.id}:${(input as { value: string }).value}` }),
}
const inventory = { tools: new Map([["echo", echo]]) }
const codeMode = CodeModeTool.create(inventory, (_, tool, input, context) => execute(tool, input, context))
const codeMode = CodeModeTool.create(
inventory,
(_, tool, input, context) => execute(tool, input, context),
() => Effect.void,
)
const run = (id: string) => {
const progress: Array<Tool.Metadata> = []
return CodeModeTool.create(inventory, (_, tool, input, context) => execute(tool, input, context))
return CodeModeTool.create(
inventory,
(_, tool, input, context) => execute(tool, input, context),
() => Effect.void,
)
.execute(
{ code: `return await tools.echo({ value: "${id}" })` },
{ ...context, id: Tool.CallID.make(id), progress: (update) => Effect.sync(() => progress.push(update)) },
Expand Down
9 changes: 4 additions & 5 deletions packages/core/test/tool-registry.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Agent } from "@opencode/core/agent"
import type { Permission } from "@opencode/core/permission"
import { Permission } from "@opencode/core/permission"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { Image } from "@opencode/core/image"
import { PluginHooks } from "@opencode/core/plugin/hooks"
Expand All @@ -14,6 +14,7 @@ import { codeModeListings, executeTool, toolDefinitions } from "./lib/tool"
import { Deferred, Effect, Exit, Fiber, Layer, Logger, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
import { z } from "zod"
import { testEffect } from "./lib/effect"
import { permissionLayer } from "./lib/permission"

const imageStore = Layer.mock(Image.Service, {
normalize: (resource, content) => {
Expand All @@ -39,6 +40,7 @@ const imageStore = Layer.mock(Image.Service, {
})
const registryLayer = AppNodeBuilder.build(LayerNode.group([Tool.node, PluginHooks.node]), [
Image.node.replace(imageStore),
Permission.node.replace(permissionLayer()),
])
const it = testEffect(registryLayer)
const identity = {
Expand Down Expand Up @@ -719,10 +721,7 @@ describe("Tool", () => {
expect(snapshot.codeModeCatalog).toBeUndefined()
expect((yield* snapshot.execute(call("mcp_lookup"))).output).toEqual({ text: "mcp_lookup" })

const hidden = yield* service.snapshot([
...denyExecute,
{ action: "mcp_lookup", resource: "*", effect: "deny" },
])
const hidden = yield* service.snapshot([...denyExecute, { action: "mcp_lookup", resource: "*", effect: "deny" }])
expect(hidden.definitions.map((tool) => tool.name)).toEqual(["bash"])
expect(yield* hidden.execute(call("mcp_lookup")).pipe(Effect.flip)).toBeInstanceOf(Tool.Error)
}),
Expand Down
Loading
Loading