mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-08 01:46:23 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db32c34e76 | ||
|
|
a8c85e8360 | ||
|
|
5345cfd942 | ||
|
|
e63c147ac3 |
@@ -9,6 +9,7 @@ import { Location } from "./location.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { Project } from "./project.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { Skill } from "./skill.js"
|
||||
import type { SessionErrors } from "./session/error.js"
|
||||
import type { Tool } from "./tool.js"
|
||||
|
||||
@@ -95,6 +96,7 @@ const layer = Layer.effect(
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const permission = yield* Permission.Service
|
||||
const skills = yield* Skill.Service
|
||||
|
||||
const resolve = Effect.fn("FileAccess.resolve")(function* (input: ResolveInput) {
|
||||
const absolute = AbsolutePath.make(resolvePath(location.directory, input.path))
|
||||
@@ -156,7 +158,21 @@ const layer = Layer.effect(
|
||||
const sibling = options && path.dirname(target.absolute) === path.dirname(options.siblingOf.absolute)
|
||||
|
||||
// Filename recovery shares the directory approval, but checks the recovered file's own read rules.
|
||||
if (!sibling) yield* authorizeExternal([target], context)
|
||||
if (target.externalDirectory && !sibling) {
|
||||
// Registered directory skills expose supporting files for reads, not mutations.
|
||||
const supporting = (yield* skills.list()).some(
|
||||
(skill) =>
|
||||
path.basename(skill.location) === "SKILL.md" &&
|
||||
FSUtil.contains(path.dirname(skill.location), target.absolute),
|
||||
)
|
||||
yield* permission.assert(
|
||||
{
|
||||
...externalDirectoryPermission(target.externalDirectory),
|
||||
...invocation(context),
|
||||
},
|
||||
supporting ? [target.externalDirectory.resource] : undefined,
|
||||
)
|
||||
}
|
||||
yield* permission.assert({
|
||||
action: "read",
|
||||
resources: [target.resource],
|
||||
@@ -170,4 +186,8 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Permission.node] })
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [FSUtil.node, Location.node, Permission.node, Skill.node],
|
||||
})
|
||||
|
||||
@@ -102,7 +102,11 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
|
||||
|
||||
export interface Interface {
|
||||
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionErrors.NotFoundError>
|
||||
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
|
||||
/** Caller-owned allowances last only for this assertion and cannot override configured denials. */
|
||||
readonly assert: (
|
||||
input: AssertInput,
|
||||
allow?: readonly string[],
|
||||
) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
|
||||
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
|
||||
readonly get: (id: ID) => Effect.Effect<Request | undefined>
|
||||
readonly forSession: (sessionID: SessionSchema.ID) => Effect.Effect<ReadonlyArray<Request>>
|
||||
@@ -165,10 +169,14 @@ const layer = Layer.effect(
|
||||
return rules.filter((rule) => Wildcard.match(input.action, rule.action))
|
||||
}
|
||||
|
||||
const evaluateInput = Effect.fnUntraced(function* (input: AssertInput) {
|
||||
const evaluateInput = Effect.fnUntraced(function* (input: AssertInput, allow: readonly string[] = []) {
|
||||
const rules = yield* configured(input.sessionID, input.agent)
|
||||
if (denied(input, rules)) return { effect: "deny" as const, rules }
|
||||
const all = [...rules, ...(yield* savedRules())]
|
||||
const all = [
|
||||
...rules,
|
||||
...(yield* savedRules()),
|
||||
...allow.map((resource): Permission.Rule => ({ action: input.action, resource, effect: "allow" })),
|
||||
]
|
||||
const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
|
||||
const effect: Permission.Effect = effects.includes("ask") ? "ask" : "allow"
|
||||
const event = yield* hooks.trigger("permission", "evaluate", {
|
||||
@@ -218,9 +226,9 @@ const layer = Layer.effect(
|
||||
return { id: value.id, effect: result.effect }
|
||||
})
|
||||
|
||||
const assert = Effect.fn("Permission.assert")((input: AssertInput) =>
|
||||
const assert = Effect.fn("Permission.assert")((input: AssertInput, allow?: readonly string[]) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* evaluateInput(input)
|
||||
const result = yield* evaluateInput(input, allow)
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
if (result.effect === "deny") {
|
||||
|
||||
@@ -207,6 +207,21 @@ describe("Agent", () => {
|
||||
expect(Permission.evaluate("read", ".env.local", explore?.permissions ?? []).effect).toBe("ask")
|
||||
expect(Permission.evaluate("read", ".env.example", explore?.permissions ?? []).effect).toBe("allow")
|
||||
expect(Permission.evaluate("read", "src/index.ts", explore?.permissions ?? []).effect).toBe("allow")
|
||||
for (const rules of [permissions, explore?.permissions ?? []]) {
|
||||
expect(Permission.evaluate("external_directory", "/unrelated/reference/*", rules).effect).toBe("ask")
|
||||
for (const directory of [
|
||||
path.join(global.data, "shell"),
|
||||
path.join(global.data, "tool-output"),
|
||||
global.tmp,
|
||||
global.config,
|
||||
]) {
|
||||
expect(Permission.evaluate("external_directory", path.join(directory, "nested", "*"), rules).effect).toBe(
|
||||
"allow",
|
||||
)
|
||||
}
|
||||
}
|
||||
expect(Permission.evaluate("edit", "notes.md", explore?.permissions ?? []).effect).toBe("deny")
|
||||
expect(Permission.evaluate("shell", "echo hello", explore?.permissions ?? []).effect).toBe("deny")
|
||||
for (const item of agents) {
|
||||
expect(item.permissions.some((rule) => rule.action === "bash" && rule.effect !== "deny")).toBe(false)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { FileAccess } from "@opencode-ai/core/file-access"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionInstructions } from "@opencode-ai/core/session/instructions"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { Skill } from "@opencode-ai/core/skill"
|
||||
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { ReadTool } from "@opencode-ai/core/tool/plugin/read"
|
||||
import { SkillTool } from "@opencode-ai/core/tool/plugin/skill"
|
||||
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
|
||||
import { Directory } from "@opencode-ai/schema/config"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
|
||||
import { agentHost, host } from "./plugin/host"
|
||||
|
||||
const sessionID = Session.ID.make("ses_skill_resources")
|
||||
|
||||
const fixture = Effect.fn("SkillResourceTest.fixture")(function* (symlink: boolean) {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir()))
|
||||
const project = path.join(tmp.path, "project")
|
||||
const config = path.join(tmp.path, "home", ".opencode")
|
||||
const source = path.join(config, "skill")
|
||||
const storage = symlink ? path.join(tmp.path, "dotfiles", "skills") : source
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
fs.mkdir(project, { recursive: true }),
|
||||
fs.mkdir(config, { recursive: true }),
|
||||
fs.mkdir(path.join(storage, "release", "references"), { recursive: true }),
|
||||
]),
|
||||
)
|
||||
if (symlink) yield* Effect.promise(() => fs.symlink(storage, source, "dir"))
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
fs.writeFile(
|
||||
path.join(storage, "release", "SKILL.md"),
|
||||
"---\nname: Release\ndescription: Release guide\n---\nRead references/policy.md",
|
||||
),
|
||||
fs.writeFile(path.join(storage, "release", "references", "policy.md"), "Release policy fixture\n"),
|
||||
]),
|
||||
)
|
||||
const layer = AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
Bus.node,
|
||||
Location.node,
|
||||
Global.node,
|
||||
Tool.node,
|
||||
Skill.node,
|
||||
Agent.node,
|
||||
Permission.node,
|
||||
FSUtil.node,
|
||||
FileAccess.node,
|
||||
ReadToolFileSystem.node,
|
||||
SessionInstructions.node,
|
||||
]),
|
||||
[
|
||||
Location.node.replace(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(project) }))),
|
||||
),
|
||||
Global.node.replace(
|
||||
Global.layerWith({
|
||||
home: path.join(tmp.path, "home"),
|
||||
config: path.join(tmp.path, "managed-config"),
|
||||
tmp: path.join(tmp.path, "managed-tmp"),
|
||||
}),
|
||||
),
|
||||
Image.node.replace(imagePassthrough),
|
||||
],
|
||||
)
|
||||
const context = yield* Layer.build(layer)
|
||||
yield* Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const agents = yield* Agent.Service
|
||||
const skills = yield* Skill.Service
|
||||
yield* database.db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make(project), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* database.db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "skill-resources",
|
||||
directory: project,
|
||||
title: "Skill resources",
|
||||
version: "test",
|
||||
agent: "build",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* AgentPlugin.Plugin.effect(host({ agent: agentHost(agents) }))
|
||||
yield* ConfigSkillPlugin.Plugin.effect(
|
||||
host({
|
||||
skill: {
|
||||
list: () => Effect.die("unused skill.list"),
|
||||
transform: skills.transform,
|
||||
reload: skills.reload,
|
||||
},
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(Config.testLayer([new Directory({ type: "directory", path: AbsolutePath.make(config) })])),
|
||||
Effect.provideService(SkillDiscovery.Service, { pull: () => Effect.succeed([]) }),
|
||||
Effect.provide(Watcher.testLayer),
|
||||
)
|
||||
yield* registerToolPlugin(SkillTool.Plugin)
|
||||
yield* registerToolPlugin(ReadTool.Plugin)
|
||||
}).pipe(Effect.provide(context))
|
||||
return { source, context }
|
||||
})
|
||||
|
||||
describe("skill supporting files", () => {
|
||||
for (const symlink of [false, true]) {
|
||||
for (const agent of ["build", "explore"]) {
|
||||
it.live(`preserves V1 reference reads before skill invocation (${agent}, symlink=${symlink})`, () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* fixture(symlink)
|
||||
yield* Effect.gen(function* () {
|
||||
const tools = yield* Tool.Service
|
||||
const bus = yield* Bus.Service
|
||||
const permission = yield* Permission.Service
|
||||
const agents = yield* Agent.Service
|
||||
expect(yield* agents.get(Agent.ID.make(agent))).toMatchObject({
|
||||
id: agent,
|
||||
mode: agent === "explore" ? "subagent" : "primary",
|
||||
})
|
||||
const requests: Permission.Request[] = []
|
||||
yield* bus.subscribe(Permission.Event.Asked).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
requests.push(event.data)
|
||||
return permission.reply({ requestID: event.data.id, reply: "once" })
|
||||
}),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
expect(
|
||||
yield* executeTool(tools, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
agent: Agent.ID.make(agent),
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: `reference-before-invocation-${agent}`,
|
||||
name: "read",
|
||||
input: { path: path.join(tmp.source, "release", "references", "policy.md") },
|
||||
},
|
||||
}),
|
||||
).toMatchObject({ status: "completed", output: { content: "Release policy fixture\n" } })
|
||||
expect(requests).toEqual([])
|
||||
yield* agents.transform((editor) =>
|
||||
editor.update(Agent.ID.make(agent), (info) => {
|
||||
info.permissions.push({ action: "external_directory", resource: "*", effect: "deny" })
|
||||
}),
|
||||
)
|
||||
expect(
|
||||
yield* executeTool(tools, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
agent: Agent.ID.make(agent),
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: `reference-denied-${agent}`,
|
||||
name: "read",
|
||||
input: { path: path.join(tmp.source, "release", "references", "policy.md") },
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
status: "error",
|
||||
error: { type: "permission.rejected", message: "Permission denied: external_directory" },
|
||||
})
|
||||
expect(requests).toEqual([])
|
||||
}).pipe(Effect.provide(tmp.context))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.live(`reads a discovered external skill reference without another prompt (symlink=${symlink})`, () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* fixture(symlink)
|
||||
yield* Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
const skills = yield* Skill.Service
|
||||
const tools = yield* Tool.Service
|
||||
const bus = yield* Bus.Service
|
||||
const permission = yield* Permission.Service
|
||||
const requests: Permission.Request[] = []
|
||||
yield* bus.subscribe(Permission.Event.Asked).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
requests.push(event.data)
|
||||
// Resolve only this isolated fixture's prompts so a regression fails by
|
||||
// assertion, rather than hanging in Permission.assert.
|
||||
return permission.reply({ requestID: event.data.id, reply: "once" })
|
||||
}),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const read = (file: string) =>
|
||||
executeTool(tools, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "read-policy", name: "read", input: { path: file } },
|
||||
})
|
||||
const reference = path.join(tmp.source, "release", "references", "policy.md")
|
||||
const rules = (permissions: Permission.Ruleset) =>
|
||||
agents.transform((editor) =>
|
||||
editor.update(Agent.ID.make("build"), (agent) => {
|
||||
agent.permissions = [...Agent.Info.default(Agent.ID.make("build")).permissions, ...permissions]
|
||||
}),
|
||||
)
|
||||
|
||||
// Documentation reads do not require invocation, even when invoking the skill is denied.
|
||||
for (const effect of ["deny", "ask", "allow"] as const) {
|
||||
yield* rules([{ action: "skill", resource: "release", effect }])
|
||||
expect(yield* read(reference)).toMatchObject({
|
||||
status: "completed",
|
||||
output: { content: "Release policy fixture\n" },
|
||||
})
|
||||
expect(requests).toEqual([])
|
||||
const invocation = yield* executeTool(tools, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: `invoke-${effect}`, name: "skill", input: { id: "release" } },
|
||||
})
|
||||
expect(invocation).toMatchObject(
|
||||
effect === "deny"
|
||||
? { status: "error", error: { type: "permission.rejected", message: "Permission denied: skill" } }
|
||||
: { status: "completed" },
|
||||
)
|
||||
expect(requests.map((request) => request.action)).toEqual(effect === "ask" ? ["skill"] : [])
|
||||
requests.length = 0
|
||||
}
|
||||
yield* rules([])
|
||||
expect(
|
||||
yield* executeTool(tools, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "load-release", name: "skill", input: { id: "release" } },
|
||||
}),
|
||||
).toMatchObject({ status: "completed" })
|
||||
const result = yield* executeTool(tools, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "read-reference",
|
||||
name: "read",
|
||||
input: { path: path.join(tmp.source, "release", "references", "policy.md") },
|
||||
},
|
||||
})
|
||||
expect(result).toMatchObject({ status: "completed", output: { content: "Release policy fixture\n" } })
|
||||
expect(requests.map(({ action, resources }) => ({ action, resources }))).toEqual([])
|
||||
|
||||
const boundary = path.join(path.dirname(reference), "*").replaceAll("\\", "/")
|
||||
|
||||
// The read's allowance is not saved or reused by another external action.
|
||||
yield* permission.assert({ sessionID, action: "external_directory", resources: [boundary] })
|
||||
expect(requests.map((request) => request.action)).toEqual(["external_directory"])
|
||||
requests.length = 0
|
||||
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: reference, kind: "file" })
|
||||
yield* access.authorizeExternal([target], {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
id: Tool.CallID.make("external-skill-resource"),
|
||||
})
|
||||
expect(requests.map((request) => request.action)).toEqual(["external_directory"])
|
||||
requests.length = 0
|
||||
|
||||
for (const action of ["read", "external_directory"]) {
|
||||
yield* rules([{ action, resource: "*", effect: "deny" }])
|
||||
expect(yield* read(reference)).toMatchObject({
|
||||
status: "error",
|
||||
error: { type: "permission.rejected", message: `Permission denied: ${action}` },
|
||||
})
|
||||
expect(requests).toEqual([])
|
||||
}
|
||||
|
||||
yield* rules([])
|
||||
const env = path.join(tmp.source, "release", "fixture.env")
|
||||
yield* Effect.promise(() => fs.writeFile(env, "fixture only\n"))
|
||||
expect(yield* read(env)).toMatchObject({ status: "completed" })
|
||||
expect(requests.map((request) => request.action)).toEqual(["read"])
|
||||
requests.length = 0
|
||||
|
||||
// A common path prefix and a flat Markdown skill must not authorize siblings.
|
||||
const sibling = path.join(tmp.source, "release-notes.md")
|
||||
yield* Effect.promise(() => fs.writeFile(sibling, "Release notes\n"))
|
||||
yield* skills.transform((editor) =>
|
||||
editor.add(
|
||||
Skill.Info.make({
|
||||
id: Skill.ID.make("flat"),
|
||||
name: Skill.Name.make("Flat"),
|
||||
location: AbsolutePath.make(path.join(tmp.source, "flat.md")),
|
||||
content: "Flat skill",
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(yield* read(sibling)).toMatchObject({ status: "completed" })
|
||||
expect(requests.map((request) => request.action)).toEqual(["external_directory"])
|
||||
}).pipe(Effect.provide(tmp.context))
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user