mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-24 08:20:11 +00:00
178 lines
7.3 KiB
TypeScript
178 lines
7.3 KiB
TypeScript
import { describe, expect } from "bun:test"
|
|
import fs from "fs/promises"
|
|
import path from "path"
|
|
import { Effect } from "effect"
|
|
import { FileAccess } from "@opencode/core/file-access"
|
|
import { Location } from "@opencode/core/location"
|
|
import { Permission } from "@opencode/core/permission"
|
|
import { Session } from "@opencode/core/session"
|
|
import { Tool } from "@opencode/core/tool"
|
|
import { AbsolutePath } from "@opencode/core/schema"
|
|
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
|
import { LayerNode } from "@opencode/util/effect/layer-node"
|
|
import { tempLocationLayer } from "./fixture/location"
|
|
import { tmpdirScoped } from "./fixture/tmpdir"
|
|
import { it } from "./lib/effect"
|
|
import { permissionLayer } from "./lib/permission"
|
|
import { toolIdentity } from "./lib/tool"
|
|
|
|
const invocation = {
|
|
...toolIdentity,
|
|
sessionID: Session.ID.make("ses_file_access"),
|
|
id: Tool.CallID.make("call-read"),
|
|
}
|
|
const slash = (file: string) => file.replaceAll("\\", "/")
|
|
|
|
function provide(requests: Permission.AssertInput[], denied?: string) {
|
|
return Effect.provide(
|
|
AppNodeBuilder.build(LayerNode.group([FileAccess.node, Location.node]), [
|
|
Location.node.replace(tempLocationLayer),
|
|
Permission.node.replace(
|
|
permissionLayer({
|
|
assert: (input) =>
|
|
Effect.gen(function* () {
|
|
requests.push(input)
|
|
if (input.action === denied)
|
|
yield* new Permission.BlockedError({
|
|
rules: [],
|
|
permission: input.action,
|
|
resources: input.resources,
|
|
})
|
|
}),
|
|
}),
|
|
),
|
|
]),
|
|
)
|
|
}
|
|
|
|
describe("FileAccess.authorizeRead", () => {
|
|
it.live("returns an absolute target and preserves invocation identity on the read assertion", () => {
|
|
const requests: Permission.AssertInput[] = []
|
|
return Effect.gen(function* () {
|
|
const access = yield* FileAccess.Service
|
|
const location = yield* Location.Service
|
|
const target = yield* access.authorizeRead("src/../README.md", invocation)
|
|
const absolute: AbsolutePath = target.absolute
|
|
|
|
expect(absolute).toBe(AbsolutePath.make(path.join(location.directory, "README.md")))
|
|
expect(target.externalDirectory).toBeUndefined()
|
|
expect(requests).toEqual([
|
|
{
|
|
action: "read",
|
|
resources: ["README.md"],
|
|
save: ["*"],
|
|
sessionID: invocation.sessionID,
|
|
agent: invocation.agent,
|
|
source: { type: "tool", messageID: invocation.messageID, id: invocation.id },
|
|
},
|
|
])
|
|
}).pipe(provide(requests))
|
|
})
|
|
|
|
it.live("authorizes an external directory before the file's read rules", () => {
|
|
const requests: Permission.AssertInput[] = []
|
|
return Effect.gen(function* () {
|
|
const access = yield* FileAccess.Service
|
|
const target = yield* access.authorizeRead("../notes.txt", invocation)
|
|
|
|
expect(requests).toMatchObject([
|
|
{ action: "external_directory", resources: [slash(path.join(path.dirname(target.absolute), "*"))] },
|
|
{ action: "read", resources: [slash(target.absolute)] },
|
|
])
|
|
for (const request of requests) {
|
|
expect(request).toMatchObject({
|
|
sessionID: invocation.sessionID,
|
|
agent: invocation.agent,
|
|
source: { type: "tool", messageID: invocation.messageID, id: invocation.id },
|
|
})
|
|
}
|
|
}).pipe(provide(requests))
|
|
})
|
|
|
|
for (const action of ["external_directory", "read"]) {
|
|
it.live(`propagates ${action} denial without continuing authorization`, () => {
|
|
const requests: Permission.AssertInput[] = []
|
|
return Effect.gen(function* () {
|
|
const access = yield* FileAccess.Service
|
|
const error = yield* access.authorizeRead("../notes.txt", invocation).pipe(Effect.flip)
|
|
|
|
expect(error).toBeInstanceOf(Permission.BlockedError)
|
|
expect(requests.map((request) => request.action)).toEqual(
|
|
action === "external_directory" ? ["external_directory"] : ["external_directory", "read"],
|
|
)
|
|
}).pipe(provide(requests, action))
|
|
})
|
|
}
|
|
|
|
it.live("reuses a sibling's directory approval only for the supplied recovery call", () => {
|
|
const requests: Permission.AssertInput[] = []
|
|
return Effect.gen(function* () {
|
|
const access = yield* FileAccess.Service
|
|
const requested = yield* access.authorizeRead("../report final.txt", invocation)
|
|
const recovered = yield* access.authorizeRead("../report\u202ffinal.txt", invocation, { siblingOf: requested })
|
|
yield* access.authorizeRead("../notes.txt", invocation)
|
|
|
|
expect(requests.map((request) => request.action)).toEqual([
|
|
"external_directory",
|
|
"read",
|
|
"read",
|
|
"external_directory",
|
|
"read",
|
|
])
|
|
expect(requests[2].resources).toEqual([slash(recovered.absolute)])
|
|
}).pipe(provide(requests))
|
|
})
|
|
|
|
it.live("checks the external directory for a target that is not a sibling", () => {
|
|
const requests: Permission.AssertInput[] = []
|
|
return Effect.gen(function* () {
|
|
const access = yield* FileAccess.Service
|
|
const requested = yield* access.authorizeRead("README.md", invocation)
|
|
yield* access.authorizeRead("../notes.txt", invocation, { siblingOf: requested })
|
|
|
|
expect(requests.map((request) => request.action)).toEqual(["read", "external_directory", "read"])
|
|
}).pipe(provide(requests))
|
|
})
|
|
|
|
it.live("batches external resources in first-seen order and preserves broader repository saves", () => {
|
|
const requests: Permission.AssertInput[] = []
|
|
return Effect.gen(function* () {
|
|
const external = yield* tmpdirScoped()
|
|
const git = path.join(external.path, "git")
|
|
const hg = path.join(external.path, "hg")
|
|
yield* Effect.promise(async () => {
|
|
await fs.mkdir(path.join(git, ".git"), { recursive: true })
|
|
await fs.mkdir(path.join(git, "nested"))
|
|
await fs.mkdir(path.join(hg, ".hg"), { recursive: true })
|
|
await fs.mkdir(path.join(hg, "nested"))
|
|
})
|
|
const access = yield* FileAccess.Service
|
|
const first = yield* access.resolve({ path: path.join(git, "nested", "a.txt"), kind: "file" })
|
|
const second = yield* access.resolve({ path: path.join(git, "nested", "b.txt"), kind: "file" })
|
|
const third = yield* access.resolve({ path: path.join(hg, "nested", "c.txt"), kind: "file" })
|
|
const internal = yield* access.resolve({ path: "README.md" })
|
|
const metadata = { filepath: first.absolute, parentDir: path.dirname(first.absolute) }
|
|
|
|
yield* access.authorizeExternal([first, internal, second, third, first], invocation, metadata)
|
|
|
|
expect(requests).toEqual([
|
|
{
|
|
action: "external_directory",
|
|
resources: [slash(path.join(git, "nested", "*")), slash(path.join(hg, "nested", "*"))],
|
|
save: [slash(path.join(git, "*")), slash(path.join(hg, "*"))],
|
|
metadata,
|
|
sessionID: invocation.sessionID,
|
|
agent: invocation.agent,
|
|
source: { type: "tool", messageID: invocation.messageID, id: invocation.id },
|
|
},
|
|
])
|
|
|
|
yield* access.authorizeExternal([internal], invocation)
|
|
expect(requests).toHaveLength(1)
|
|
yield* access.authorizeExternal([second], invocation)
|
|
expect(requests).toHaveLength(2)
|
|
expect(requests[1].resources).toEqual([slash(path.join(git, "nested", "*"))])
|
|
expect(Object.hasOwn(requests[1], "metadata")).toBe(false)
|
|
}).pipe(provide(requests))
|
|
})
|
|
})
|