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
18 changes: 18 additions & 0 deletions packages/core/src/config/plugin/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ConfigAgentV1 } from "../../v1/config/agent"
import { ConfigMigrateV1 } from "../../v1/config/migrate"
import { Global } from "../../global"
import { PermissionV2 } from "../../permission"
import { SHELL_OUTPUT_GLOB } from "../../permission/defaults"
import type { LocationMutation } from "../../location-mutation"
import type { ReadTool } from "../../tool/read"
import type { EditTool } from "../../tool/edit"
Expand Down Expand Up @@ -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(
Stream.filter((event) => event.type === "config.updated"),
Expand Down
74 changes: 46 additions & 28 deletions packages/core/src/config/plugin/reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { Reference } from "../../reference"
import { AbsolutePath } from "../../schema"
import { Global } from "../../global"
import { Location } from "../../location"
import { allowExternalDirectories } from "../../permission/defaults"
import { Repository } from "../../repository"

export const Plugin = define({
id: "opencode.config.reference",
Expand All @@ -18,49 +20,65 @@ export const Plugin = define({
const global = yield* Global.Service
const loaded = { entries: yield* config.entries() }
yield* ctx.reference.transform((draft) => {
const entries = new Map<string, Reference.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
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
entries.set(
name,
local(entry)
? Reference.LocalSource.make({
type: "local",
path: AbsolutePath.make(
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 sources(loaded.entries, location.directory, global.home)) draft.add(name, source)
})
yield* ctx.agent.transform((draft) => {
const permissions = allowExternalDirectories(
Array.from(sources(loaded.entries, location.directory, global.home)).flatMap(([, source]) => {
if (source.type === "local") return [path.join(source.path, "*")]
const repository = Repository.parse(source.repository)
if (!repository || !Repository.isRemote(repository)) return []
return [path.join(Repository.cachePath(global.repos, repository), "*")]
}),
)
for (const current of draft.list()) {
draft.update(current.id, (agent) => agent.permissions.push(...permissions))
}
for (const [name, source] of entries) draft.add(name, source)
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.reference.reload()),
Effect.andThen(ctx.agent.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})

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) {
return name.length > 0 && !/[\/\s`,]/.test(name)
}
Expand Down
85 changes: 50 additions & 35 deletions packages/core/src/config/plugin/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { AbsolutePath } from "../../schema"
import { SkillV2 } from "../../skill"
import { Global } from "../../global"
import { Location } from "../../location"
import { SkillDiscovery } from "../../skill/discovery"
import { allowExternalDirectories } from "../../permission/defaults"

export const Plugin = define({
id: "opencode.config.skill",
Expand All @@ -17,41 +19,19 @@ export const Plugin = define({
const location = yield* Location.Service
const loaded = { entries: yield* config.entries() }
yield* ctx.skill.transform((draft) => {
const claude = loaded.entries.flatMap((entry) => (entry.type === "claude" ? [entry.path] : []))
const agents = loaded.entries.flatMap((entry) => (entry.type === "agents" ? [entry.path] : []))
const directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
for (const directory of [...claude, ...agents]) {
draft.source(
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join(directory, "skills")),
}),
)
}
for (const directory of directories) {
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)),
}),
)
for (const source of sources(loaded.entries, global.home, location.directory)) draft.source(source)
})
yield* ctx.agent.transform((draft) => {
const permissions = allowExternalDirectories(
sources(loaded.entries, global.home, location.directory).map((source) =>
path.join(
source.type === "directory" ? source.path : SkillDiscovery.cachePath(global.cache, source.url),
"*",
),
),
)
for (const current of draft.list()) {
draft.update(current.id, (agent) => agent.permissions.push(...permissions))
}
})
yield* ctx.event.subscribe().pipe(
Expand All @@ -60,9 +40,44 @@ export const Plugin = define({
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.skill.reload()),
Effect.andThen(ctx.agent.reload()),
),
),
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
}
10 changes: 10 additions & 0 deletions packages/core/src/permission/defaults.ts
Original file line number Diff line number Diff line change
@@ -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" }))
}
4 changes: 1 addition & 3 deletions packages/core/src/plugin/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,8 @@ import { AgentV2 } from "../agent"
import { Global } from "../global"
import { Location } from "../location"
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 =
"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."

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/plugin/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,9 @@ const pre = [

const post = [
ConfigReferencePlugin.Plugin,
ConfigSkillPlugin.Plugin,
ConfigAgentPlugin.Plugin,
ConfigCommandPlugin.Plugin,
ConfigSkillPlugin.Plugin,
ConfigProviderPlugin.Plugin,
VariantPlugin.Plugin,
ConfigPolicyPlugin.Plugin,
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/skill/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ export interface Interface {

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(
Service,
Effect.gen(function* () {
Expand Down Expand Up @@ -111,7 +115,7 @@ const layer = Layer.effect(
)
if (!data) return []

const sourceRoot = path.resolve(global.cache, "skills", Hash.fast(base))
const sourceRoot = cachePath(global.cache, base)
return yield* Effect.forEach(
data.skills.flatMap((skill) => {
if (!isSafeSegment(skill.name)) {
Expand Down
Loading
Loading