export * as FileAccess from "./file-access.js" import { makeLocationNode } from "@opencode/util/effect/app-node" import { FSUtil } from "@opencode/util/fs-util" import { Global } from "@opencode/util/global" import { Array, Context, Effect, Layer, Schema } from "effect" import path from "path" import { Location } from "./location.js" import { Permission } from "./permission.js" import { Project } from "./project.js" import { AbsolutePath } from "./schema.js" import type { SessionErrors } from "./session/error.js" import type { Tool } from "./tool.js" export const Kind = Schema.Literals(["file", "directory"]) export type Kind = typeof Kind.Type export const ResolveInput = Schema.Struct({ path: Schema.String, /** Selects the external approval boundary; it does not validate the target type. */ kind: Kind.pipe(Schema.optional), }) export type ResolveInput = typeof ResolveInput.Type export interface ExternalDirectoryAuthorization { readonly action: "external_directory" /** Lexical directory used as the external approval boundary. */ readonly directory: AbsolutePath readonly resource: string readonly save: string } export const externalDirectoryPermission = (input: ExternalDirectoryAuthorization) => ({ action: input.action, resources: [input.resource], save: [input.save], }) export interface Target { readonly absolute: AbsolutePath /** Location-relative for internal paths, absolute for external paths. */ readonly resource: string readonly externalDirectory?: ExternalDirectoryAuthorization } export type Invocation = Pick export interface ReadOptions { /** A target already authorized by this invocation, used for filename recovery. */ readonly siblingOf: Target } export interface Interface { /** Resolve a lexical path and its permission resources, without requesting approval. */ readonly resolve: (input: ResolveInput) => Effect.Effect /** Approve external directories in one batch, preserving first-seen resource order. */ readonly authorizeExternal: ( targets: readonly Target[], context: Invocation, metadata?: Permission.AssertInput["metadata"], ) => Effect.Effect /** Resolve a read target and obtain external-directory approval before read approval. */ readonly authorizeRead: ( file: string, context: Invocation, options?: ReadOptions, ) => Effect.Effect } export class Service extends Context.Service()("@opencode/FileAccess") {} /** Expand a leading ~ and normalize Windows shell paths before lexical resolution. */ export const resolvePath = (directory: string, input: string, home = Global.Path.home) => { const normalized = FSUtil.windowsPath(input) return path.resolve( directory, normalized === "~" ? home : normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\")) ? path.join(home, normalized.slice(2)) : normalized, ) } const slash = (value: string) => value.replaceAll("\\", "/") const invocation = (context: Invocation) => ({ sessionID: context.sessionID, agent: context.agent, source: { type: "tool" as const, messageID: context.messageID, id: context.id }, }) const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service const location = yield* Location.Service const permission = yield* Permission.Service const resolve = Effect.fn("FileAccess.resolve")(function* (input: ResolveInput) { const absolute = AbsolutePath.make(resolvePath(location.directory, input.path)) const worktree = path.resolve(location.project.directory) const internal = FSUtil.contains(location.directory, absolute) || (worktree !== path.parse(worktree).root && FSUtil.contains(worktree, absolute)) if (internal) { return { absolute, resource: slash(path.relative(location.directory, absolute) || "."), } satisfies Target } const type = input.kind === "directory" ? "Directory" : input.kind === "file" ? "File" : (yield* fs.stat(absolute).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined))) ?.type const directory = AbsolutePath.make(type === "Directory" ? absolute : path.dirname(absolute)) return { absolute, resource: slash(absolute), externalDirectory: { action: "external_directory", directory, resource: slash(path.join(directory, "*")), save: slash(path.join((yield* Project.root(fs, directory)) ?? directory, "*")), }, } satisfies Target }) const authorizeExternal = Effect.fn("FileAccess.authorizeExternal")(function* ( targets: readonly Target[], context: Invocation, metadata?: Permission.AssertInput["metadata"], ) { const external = Array.dedupeWith( targets.flatMap((target) => (target.externalDirectory ? [target.externalDirectory] : [])), (left, right) => left.resource === right.resource, ) if (external.length === 0) return yield* permission.assert({ action: "external_directory", resources: external.map((item) => item.resource), save: external.map((item) => item.save), ...(metadata === undefined ? {} : { metadata }), ...invocation(context), }) }) const authorizeRead = Effect.fn("FileAccess.authorizeRead")(function* ( file: string, context: Invocation, options?: ReadOptions, ) { const target = yield* resolve({ path: file, kind: options ? "file" : undefined }) 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) yield* permission.assert({ action: "read", resources: [target.resource], save: ["*"], ...invocation(context), }) return target }) return Service.of({ resolve, authorizeExternal, authorizeRead }) }), ) export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Permission.node] })