mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 09:10:47 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| edf0ce766d |
@@ -12,6 +12,7 @@ import { ConfigAgentV1 } from "../../v1/config/agent"
|
|||||||
import { ConfigMigrateV1 } from "../../v1/config/migrate"
|
import { ConfigMigrateV1 } from "../../v1/config/migrate"
|
||||||
import { Global } from "../../global"
|
import { Global } from "../../global"
|
||||||
import { PermissionV2 } from "../../permission"
|
import { PermissionV2 } from "../../permission"
|
||||||
|
import { SHELL_OUTPUT_GLOB } from "../../permission/defaults"
|
||||||
import type { LocationMutation } from "../../location-mutation"
|
import type { LocationMutation } from "../../location-mutation"
|
||||||
import type { ReadTool } from "../../tool/read"
|
import type { ReadTool } from "../../tool/read"
|
||||||
import type { EditTool } from "../../tool/edit"
|
import type { EditTool } from "../../tool/edit"
|
||||||
@@ -112,6 +113,23 @@ export const Plugin = define({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Internal shell output remains readable through broad external-directory denials.
|
||||||
|
// An exact deny still lets users explicitly revoke access to these files.
|
||||||
|
for (const current of draft.list()) {
|
||||||
|
draft.update(current.id, (agent) => {
|
||||||
|
const denied = agent.permissions.some(
|
||||||
|
(rule) =>
|
||||||
|
rule.action === "external_directory" && rule.resource === SHELL_OUTPUT_GLOB && rule.effect === "deny",
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
denied ||
|
||||||
|
PermissionV2.evaluate("external_directory", SHELL_OUTPUT_GLOB, agent.permissions).effect === "allow"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
agent.permissions.push({ action: "external_directory", resource: SHELL_OUTPUT_GLOB, effect: "allow" })
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
yield* ctx.event.subscribe().pipe(
|
yield* ctx.event.subscribe().pipe(
|
||||||
Stream.filter((event) => event.type === "config.updated"),
|
Stream.filter((event) => event.type === "config.updated"),
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { Reference } from "../../reference"
|
|||||||
import { AbsolutePath } from "../../schema"
|
import { AbsolutePath } from "../../schema"
|
||||||
import { Global } from "../../global"
|
import { Global } from "../../global"
|
||||||
import { Location } from "../../location"
|
import { Location } from "../../location"
|
||||||
|
import { allowExternalDirectories } from "../../permission/defaults"
|
||||||
|
import { Repository } from "../../repository"
|
||||||
|
|
||||||
export const Plugin = define({
|
export const Plugin = define({
|
||||||
id: "opencode.config.reference",
|
id: "opencode.config.reference",
|
||||||
@@ -18,35 +20,20 @@ export const Plugin = define({
|
|||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
const loaded = { entries: yield* config.entries() }
|
const loaded = { entries: yield* config.entries() }
|
||||||
yield* ctx.reference.transform((draft) => {
|
yield* ctx.reference.transform((draft) => {
|
||||||
const entries = new Map<string, Reference.Source>()
|
for (const [name, source] of sources(loaded.entries, location.directory, global.home)) draft.add(name, source)
|
||||||
for (const doc of loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")) {
|
})
|
||||||
const directory = doc.path ? path.dirname(doc.path) : location.directory
|
yield* ctx.agent.transform((draft) => {
|
||||||
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
const permissions = allowExternalDirectories(
|
||||||
if (!validAlias(name)) continue
|
Array.from(sources(loaded.entries, location.directory, global.home)).flatMap(([, source]) => {
|
||||||
const description = typeof entry === "string" ? undefined : entry.description
|
if (source.type === "local") return [path.join(source.path, "*")]
|
||||||
const hidden = typeof entry === "string" ? undefined : entry.hidden
|
const repository = Repository.parse(source.repository)
|
||||||
entries.set(
|
if (!repository || !Repository.isRemote(repository)) return []
|
||||||
name,
|
return [path.join(Repository.cachePath(global.repos, repository), "*")]
|
||||||
local(entry)
|
}),
|
||||||
? Reference.LocalSource.make({
|
)
|
||||||
type: "local",
|
for (const current of draft.list()) {
|
||||||
path: AbsolutePath.make(
|
draft.update(current.id, (agent) => agent.permissions.push(...permissions))
|
||||||
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
|
|
||||||
),
|
|
||||||
...(description === undefined ? {} : { description }),
|
|
||||||
...(hidden === undefined ? {} : { hidden }),
|
|
||||||
})
|
|
||||||
: Reference.GitSource.make({
|
|
||||||
type: "git",
|
|
||||||
repository: typeof entry === "string" ? entry : entry.repository,
|
|
||||||
...(entry.branch === undefined ? {} : { branch: entry.branch }),
|
|
||||||
...(description === undefined ? {} : { description }),
|
|
||||||
...(hidden === undefined ? {} : { hidden }),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for (const [name, source] of entries) draft.add(name, source)
|
|
||||||
})
|
})
|
||||||
yield* ctx.event.subscribe().pipe(
|
yield* ctx.event.subscribe().pipe(
|
||||||
Stream.filter((event) => event.type === "config.updated"),
|
Stream.filter((event) => event.type === "config.updated"),
|
||||||
@@ -54,6 +41,7 @@ export const Plugin = define({
|
|||||||
config.entries().pipe(
|
config.entries().pipe(
|
||||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||||
Effect.andThen(ctx.reference.reload()),
|
Effect.andThen(ctx.reference.reload()),
|
||||||
|
Effect.andThen(ctx.agent.reload()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Effect.forkScoped({ startImmediately: true }),
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
@@ -61,6 +49,36 @@ export const Plugin = define({
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function sources(entries: readonly Config.Entry[], location: string, home: string) {
|
||||||
|
const result = new Map<string, Reference.Source>()
|
||||||
|
for (const doc of entries.filter((entry): entry is Config.Document => entry.type === "document")) {
|
||||||
|
const directory = doc.path ? path.dirname(doc.path) : location
|
||||||
|
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
||||||
|
if (!validAlias(name)) continue
|
||||||
|
const description = typeof entry === "string" ? undefined : entry.description
|
||||||
|
const hidden = typeof entry === "string" ? undefined : entry.hidden
|
||||||
|
result.set(
|
||||||
|
name,
|
||||||
|
local(entry)
|
||||||
|
? Reference.LocalSource.make({
|
||||||
|
type: "local",
|
||||||
|
path: AbsolutePath.make(localPath(directory, home, typeof entry === "string" ? entry : entry.path)),
|
||||||
|
...(description === undefined ? {} : { description }),
|
||||||
|
...(hidden === undefined ? {} : { hidden }),
|
||||||
|
})
|
||||||
|
: Reference.GitSource.make({
|
||||||
|
type: "git",
|
||||||
|
repository: typeof entry === "string" ? entry : entry.repository,
|
||||||
|
...(entry.branch === undefined ? {} : { branch: entry.branch }),
|
||||||
|
...(description === undefined ? {} : { description }),
|
||||||
|
...(hidden === undefined ? {} : { hidden }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
function validAlias(name: string) {
|
function validAlias(name: string) {
|
||||||
return name.length > 0 && !/[\/\s`,]/.test(name)
|
return name.length > 0 && !/[\/\s`,]/.test(name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { AbsolutePath } from "../../schema"
|
|||||||
import { SkillV2 } from "../../skill"
|
import { SkillV2 } from "../../skill"
|
||||||
import { Global } from "../../global"
|
import { Global } from "../../global"
|
||||||
import { Location } from "../../location"
|
import { Location } from "../../location"
|
||||||
|
import { SkillDiscovery } from "../../skill/discovery"
|
||||||
|
import { allowExternalDirectories } from "../../permission/defaults"
|
||||||
|
|
||||||
export const Plugin = define({
|
export const Plugin = define({
|
||||||
id: "opencode.config.skill",
|
id: "opencode.config.skill",
|
||||||
@@ -17,41 +19,19 @@ export const Plugin = define({
|
|||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const loaded = { entries: yield* config.entries() }
|
const loaded = { entries: yield* config.entries() }
|
||||||
yield* ctx.skill.transform((draft) => {
|
yield* ctx.skill.transform((draft) => {
|
||||||
const claude = loaded.entries.flatMap((entry) => (entry.type === "claude" ? [entry.path] : []))
|
for (const source of sources(loaded.entries, global.home, location.directory)) draft.source(source)
|
||||||
const agents = loaded.entries.flatMap((entry) => (entry.type === "agents" ? [entry.path] : []))
|
})
|
||||||
const directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
|
yield* ctx.agent.transform((draft) => {
|
||||||
const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
|
const permissions = allowExternalDirectories(
|
||||||
for (const directory of [...claude, ...agents]) {
|
sources(loaded.entries, global.home, location.directory).map((source) =>
|
||||||
draft.source(
|
path.join(
|
||||||
SkillV2.DirectorySource.make({
|
source.type === "directory" ? source.path : SkillDiscovery.cachePath(global.cache, source.url),
|
||||||
type: "directory",
|
"*",
|
||||||
path: AbsolutePath.make(path.join(directory, "skills")),
|
),
|
||||||
}),
|
),
|
||||||
)
|
)
|
||||||
}
|
for (const current of draft.list()) {
|
||||||
for (const directory of directories) {
|
draft.update(current.id, (agent) => agent.permissions.push(...permissions))
|
||||||
draft.source(
|
|
||||||
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
|
|
||||||
)
|
|
||||||
draft.source(
|
|
||||||
SkillV2.DirectorySource.make({
|
|
||||||
type: "directory",
|
|
||||||
path: AbsolutePath.make(path.join(directory, "skills")),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
for (const item of items) {
|
|
||||||
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
|
|
||||||
draft.source(SkillV2.UrlSource.make({ type: "url", url: item }))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
|
|
||||||
draft.source(
|
|
||||||
SkillV2.DirectorySource.make({
|
|
||||||
type: "directory",
|
|
||||||
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
yield* ctx.event.subscribe().pipe(
|
yield* ctx.event.subscribe().pipe(
|
||||||
@@ -60,9 +40,44 @@ export const Plugin = define({
|
|||||||
config.entries().pipe(
|
config.entries().pipe(
|
||||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||||
Effect.andThen(ctx.skill.reload()),
|
Effect.andThen(ctx.skill.reload()),
|
||||||
|
Effect.andThen(ctx.agent.reload()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Effect.forkScoped({ startImmediately: true }),
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function sources(entries: readonly Config.Entry[], home: string, directory: string) {
|
||||||
|
const result: Array<SkillV2.DirectorySource | SkillV2.UrlSource> = []
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.type === "claude" || entry.type === "agents") {
|
||||||
|
result.push(
|
||||||
|
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(entry.path, "skills")) }),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (entry.type === "directory") {
|
||||||
|
result.push(
|
||||||
|
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(entry.path, "skill")) }),
|
||||||
|
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(entry.path, "skills")) }),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (entry.type !== "document") continue
|
||||||
|
for (const item of entry.info.skills ?? []) {
|
||||||
|
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
|
||||||
|
result.push(SkillV2.UrlSource.make({ type: "url", url: item }))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const expanded = item.startsWith("~/") ? path.join(home, item.slice(2)) : item
|
||||||
|
result.push(
|
||||||
|
SkillV2.DirectorySource.make({
|
||||||
|
type: "directory",
|
||||||
|
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import path from "path"
|
||||||
|
import { Global } from "../global"
|
||||||
|
import { PermissionV2 } from "../permission"
|
||||||
|
|
||||||
|
// Combined output files written by the Shell service, e.g. `<data>/shell/<projectID>/<shellID>.out`.
|
||||||
|
export const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
|
||||||
|
|
||||||
|
export function allowExternalDirectories(resources: readonly string[]): PermissionV2.Ruleset {
|
||||||
|
return resources.map((resource): PermissionV2.Rule => ({ action: "external_directory", resource, effect: "allow" }))
|
||||||
|
}
|
||||||
@@ -7,10 +7,8 @@ import { AgentV2 } from "../agent"
|
|||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { Location } from "../location"
|
import { Location } from "../location"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
|
import { SHELL_OUTPUT_GLOB } from "../permission/defaults"
|
||||||
|
|
||||||
// Combined output files written by the Shell service, e.g. `<data>/shell/<projectID>/<shellID>.out`.
|
|
||||||
// Whitelisted so agents can read a command's full captured output without an external-directory prompt.
|
|
||||||
const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
|
|
||||||
const BUILD_SYSTEM =
|
const BUILD_SYSTEM =
|
||||||
"You are an AI coding agent. Help the user accomplish software engineering tasks by inspecting the workspace, making targeted changes, and using tools according to the configured permissions."
|
"You are an AI coding agent. Help the user accomplish software engineering tasks by inspecting the workspace, making targeted changes, and using tools according to the configured permissions."
|
||||||
|
|
||||||
|
|||||||
@@ -147,9 +147,9 @@ const pre = [
|
|||||||
|
|
||||||
const post = [
|
const post = [
|
||||||
ConfigReferencePlugin.Plugin,
|
ConfigReferencePlugin.Plugin,
|
||||||
|
ConfigSkillPlugin.Plugin,
|
||||||
ConfigAgentPlugin.Plugin,
|
ConfigAgentPlugin.Plugin,
|
||||||
ConfigCommandPlugin.Plugin,
|
ConfigCommandPlugin.Plugin,
|
||||||
ConfigSkillPlugin.Plugin,
|
|
||||||
ConfigProviderPlugin.Plugin,
|
ConfigProviderPlugin.Plugin,
|
||||||
VariantPlugin.Plugin,
|
VariantPlugin.Plugin,
|
||||||
ConfigPolicyPlugin.Plugin,
|
ConfigPolicyPlugin.Plugin,
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ export interface Interface {
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SkillDiscovery") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SkillDiscovery") {}
|
||||||
|
|
||||||
|
export function cachePath(cache: string, url: string) {
|
||||||
|
return path.resolve(cache, "skills", Hash.fast(url.endsWith("/") ? url : `${url}/`))
|
||||||
|
}
|
||||||
|
|
||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -111,7 +115,7 @@ const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
if (!data) return []
|
if (!data) return []
|
||||||
|
|
||||||
const sourceRoot = path.resolve(global.cache, "skills", Hash.fast(base))
|
const sourceRoot = cachePath(global.cache, base)
|
||||||
return yield* Effect.forEach(
|
return yield* Effect.forEach(
|
||||||
data.skills.flatMap((skill) => {
|
data.skills.flatMap((skill) => {
|
||||||
if (!isSafeSegment(skill.name)) {
|
if (!isSafeSegment(skill.name)) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||||
|
import { SHELL_OUTPUT_GLOB } from "@opencode-ai/core/permission/defaults"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
|
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
|
||||||
import { tmpdir } from "../fixture/tmpdir"
|
import { tmpdir } from "../fixture/tmpdir"
|
||||||
@@ -22,6 +23,11 @@ const defaultPermissions = [
|
|||||||
{ action: "*", resource: "*", effect: "allow" },
|
{ action: "*", resource: "*", effect: "allow" },
|
||||||
{ action: "external_directory", resource: "*", effect: "ask" },
|
{ action: "external_directory", resource: "*", effect: "ask" },
|
||||||
] satisfies PermissionV2.Ruleset
|
] satisfies PermissionV2.Ruleset
|
||||||
|
const shellOutputPermission = {
|
||||||
|
action: "external_directory",
|
||||||
|
resource: SHELL_OUTPUT_GLOB,
|
||||||
|
effect: "allow",
|
||||||
|
} satisfies PermissionV2.Rule
|
||||||
|
|
||||||
describe("ConfigAgentPlugin.Plugin", () => {
|
describe("ConfigAgentPlugin.Plugin", () => {
|
||||||
it.effect("matches POSIX paths against home-relative permissions", () =>
|
it.effect("matches POSIX paths against home-relative permissions", () =>
|
||||||
@@ -114,6 +120,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||||||
{ action: "bash", resource: "*", effect: "ask" },
|
{ action: "bash", resource: "*", effect: "ask" },
|
||||||
{ action: "read", resource: "*", effect: "allow" },
|
{ action: "read", resource: "*", effect: "allow" },
|
||||||
{ action: "bash", resource: "git *", effect: "allow" },
|
{ action: "bash", resource: "git *", effect: "allow" },
|
||||||
|
shellOutputPermission,
|
||||||
])
|
])
|
||||||
expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("allow")
|
expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("allow")
|
||||||
expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).effect).toBe("ask")
|
expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).effect).toBe("ask")
|
||||||
@@ -132,6 +139,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||||||
{ action: "read", resource: "*", effect: "allow" },
|
{ action: "read", resource: "*", effect: "allow" },
|
||||||
{ action: "edit", resource: "*", effect: "deny" },
|
{ action: "edit", resource: "*", effect: "deny" },
|
||||||
{ action: "read", resource: "*", effect: "deny" },
|
{ action: "read", resource: "*", effect: "deny" },
|
||||||
|
shellOutputPermission,
|
||||||
])
|
])
|
||||||
expect(PermissionV2.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny")
|
expect(PermissionV2.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny")
|
||||||
expect((yield* agents.get(AgentV2.ID.make("late")))?.permissions).toEqual([
|
expect((yield* agents.get(AgentV2.ID.make("late")))?.permissions).toEqual([
|
||||||
@@ -139,11 +147,31 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||||||
{ action: "bash", resource: "*", effect: "ask" },
|
{ action: "bash", resource: "*", effect: "ask" },
|
||||||
{ action: "read", resource: "*", effect: "allow" },
|
{ action: "read", resource: "*", effect: "allow" },
|
||||||
{ action: "edit", resource: "*", effect: "allow" },
|
{ action: "edit", resource: "*", effect: "allow" },
|
||||||
|
shellOutputPermission,
|
||||||
])
|
])
|
||||||
expect(yield* agents.get(AgentV2.ID.make("removed"))).toBeUndefined()
|
expect(yield* agents.get(AgentV2.ID.make("removed"))).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("keeps shell output readable through a broad external-directory deny", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const permissions = yield* loadConfiguredPermissions([
|
||||||
|
{ action: "external_directory", resource: "*", effect: "deny" },
|
||||||
|
])
|
||||||
|
expect(PermissionV2.evaluate("external_directory", SHELL_OUTPUT_GLOB, permissions).effect).toBe("allow")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("respects an exact shell output deny", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const permissions = yield* loadConfiguredPermissions([
|
||||||
|
{ action: "external_directory", resource: "*", effect: "deny" },
|
||||||
|
{ action: "external_directory", resource: SHELL_OUTPUT_GLOB, effect: "deny" },
|
||||||
|
])
|
||||||
|
expect(PermissionV2.evaluate("external_directory", SHELL_OUTPUT_GLOB, permissions).effect).toBe("deny")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("maps configured agent fields and preserves an unspecified model variant", () =>
|
it.effect("maps configured agent fields and preserves an unspecified model variant", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const agents = yield* AgentV2.Service
|
const agents = yield* AgentV2.Service
|
||||||
@@ -294,13 +322,21 @@ Use native v2 fields.`,
|
|||||||
system: "Review carefully.",
|
system: "Review carefully.",
|
||||||
description: "Markdown description",
|
description: "Markdown description",
|
||||||
request: { body: { temperature: 0.5 } },
|
request: { body: { temperature: 0.5 } },
|
||||||
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
|
permissions: [
|
||||||
|
...defaultPermissions,
|
||||||
|
{ action: "edit", resource: "*", effect: "deny" },
|
||||||
|
shellOutputPermission,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
expect(yield* agents.get(AgentV2.ID.make("team/helper"))).toMatchObject({ system: "Help the team." })
|
expect(yield* agents.get(AgentV2.ID.make("team/helper"))).toMatchObject({ system: "Help the team." })
|
||||||
expect(yield* agents.get(AgentV2.ID.make("native"))).toMatchObject({
|
expect(yield* agents.get(AgentV2.ID.make("native"))).toMatchObject({
|
||||||
system: "Use native v2 fields.",
|
system: "Use native v2 fields.",
|
||||||
request: { headers: { "x-agent": "native" }, body: { effort: "high" } },
|
request: { headers: { "x-agent": "native" }, body: { effort: "high" } },
|
||||||
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
|
permissions: [
|
||||||
|
...defaultPermissions,
|
||||||
|
{ action: "edit", resource: "*", effect: "deny" },
|
||||||
|
shellOutputPermission,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
expect(yield* agents.get(AgentV2.ID.make("disabled"))).toBeUndefined()
|
expect(yield* agents.get(AgentV2.ID.make("disabled"))).toBeUndefined()
|
||||||
expect(yield* agents.get(AgentV2.ID.make("plan"))).toMatchObject({ system: "Make a plan.", mode: "primary" })
|
expect(yield* agents.get(AgentV2.ID.make("plan"))).toMatchObject({ system: "Make a plan.", mode: "primary" })
|
||||||
@@ -357,3 +393,26 @@ function loadHomePermissions(home: string) {
|
|||||||
return agent.permissions
|
return agent.permissions
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadConfiguredPermissions(permissions: PermissionV2.Ruleset) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const agents = yield* AgentV2.Service
|
||||||
|
const build = AgentV2.ID.make("build")
|
||||||
|
yield* agents.transform((draft) => draft.update(build, () => {}))
|
||||||
|
const config = Config.Service.of({
|
||||||
|
entries: () =>
|
||||||
|
Effect.succeed([
|
||||||
|
new Config.Document({
|
||||||
|
type: "document",
|
||||||
|
info: decode({ permissions }),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
|
||||||
|
Effect.provideService(Config.Service, config),
|
||||||
|
)
|
||||||
|
const agent = yield* agents.get(build)
|
||||||
|
if (!agent) throw new Error("expected configured build agent")
|
||||||
|
return agent.permissions
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,8 +34,10 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
|||||||
return { dispose }
|
return { dispose }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const agent = host().agent
|
||||||
yield* ConfigSkillPlugin.Plugin.effect(
|
yield* ConfigSkillPlugin.Plugin.effect(
|
||||||
host({
|
host({
|
||||||
|
agent: { ...agent, transform: () => Effect.succeed({ dispose: Effect.void }) },
|
||||||
skill: { list: () => Effect.die("unused skill.list"), transform, reload: () => Effect.void },
|
skill: { list: () => Effect.die("unused skill.list"), transform, reload: () => Effect.void },
|
||||||
}),
|
}),
|
||||||
).pipe(
|
).pipe(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
|||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
|
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||||
@@ -112,6 +113,70 @@ describe("LocationServiceMap", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("allows external skill and reference directories by default", () =>
|
||||||
|
Effect.acquireRelease(
|
||||||
|
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||||
|
(dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
|
||||||
|
).pipe(
|
||||||
|
Effect.flatMap(([project, external]) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const skill = path.join(external.path, "skills", "example")
|
||||||
|
const reference = path.join(external.path, "reference")
|
||||||
|
yield* Effect.promise(() =>
|
||||||
|
Promise.all([
|
||||||
|
fs.mkdir(skill, { recursive: true }),
|
||||||
|
fs.mkdir(reference, { recursive: true }),
|
||||||
|
fs.writeFile(
|
||||||
|
path.join(project.path, "opencode.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
skills: [path.join(external.path, "skills")],
|
||||||
|
references: { docs: reference },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
yield* Effect.promise(() =>
|
||||||
|
fs.writeFile(
|
||||||
|
path.join(skill, "SKILL.md"),
|
||||||
|
"---\nname: example\ndescription: Example skill.\n---\n\n# Example\n",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const locations = yield* LocationServiceMap.Service
|
||||||
|
const context = locations.get(Location.Ref.make({ directory: AbsolutePath.make(project.path) }))
|
||||||
|
const permissions = yield* Effect.gen(function* () {
|
||||||
|
const supervisor = yield* PluginSupervisor.Service
|
||||||
|
const agents = yield* AgentV2.Service
|
||||||
|
yield* supervisor.flush
|
||||||
|
for (let attempt = 0; attempt < 100; attempt++) {
|
||||||
|
const build = yield* agents.resolve("build")
|
||||||
|
if (
|
||||||
|
build &&
|
||||||
|
PermissionV2.evaluate(
|
||||||
|
"external_directory",
|
||||||
|
path.join(skill, "reference", "notes.md"),
|
||||||
|
build.permissions,
|
||||||
|
).effect === "allow" &&
|
||||||
|
PermissionV2.evaluate("external_directory", path.join(reference, "notes.md"), build.permissions)
|
||||||
|
.effect === "allow"
|
||||||
|
)
|
||||||
|
return build.permissions
|
||||||
|
yield* Effect.sleep("20 millis")
|
||||||
|
}
|
||||||
|
return (yield* agents.resolve("build"))?.permissions ?? []
|
||||||
|
}).pipe(Effect.scoped, Effect.provide(context))
|
||||||
|
|
||||||
|
expect(
|
||||||
|
PermissionV2.evaluate("external_directory", path.join(skill, "reference", "notes.md"), permissions).effect,
|
||||||
|
).toBe("allow")
|
||||||
|
expect(
|
||||||
|
PermissionV2.evaluate("external_directory", path.join(reference, "notes.md"), permissions).effect,
|
||||||
|
).toBe("allow")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
itWithSdk.live("reruns activation for SDK plugins registered during startup", () =>
|
itWithSdk.live("reruns activation for SDK plugins registered during startup", () =>
|
||||||
Effect.acquireRelease(
|
Effect.acquireRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
|
|||||||
Reference in New Issue
Block a user