feat(core): add provider policy enforcement
This commit is contained in:
parent
9e556b0f6c
commit
e24b589da1
22 changed files with 946 additions and 22 deletions
|
|
@ -7,6 +7,7 @@ import { PluginV2 } from "./plugin"
|
|||
import { ProviderV2 } from "./provider"
|
||||
import { Location } from "./location"
|
||||
import { EventV2 } from "./event"
|
||||
import { Policy } from "./policy"
|
||||
|
||||
export type ProviderRecord = {
|
||||
provider: ProviderV2.Info
|
||||
|
|
@ -25,6 +26,8 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundErr
|
|||
modelID: ModelV2.ID,
|
||||
}) {}
|
||||
|
||||
export const PolicyActions = Schema.Literals(["provider.use"])
|
||||
|
||||
export const Event = {
|
||||
ModelUpdated: EventV2.define({
|
||||
type: "catalog.model.updated",
|
||||
|
|
@ -84,6 +87,7 @@ export const layer = Layer.effect(
|
|||
let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined
|
||||
const plugin = yield* PluginV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const policy = yield* Policy.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const resolve = (model: ModelV2.Info) => {
|
||||
|
|
@ -199,16 +203,23 @@ export const layer = Layer.effect(
|
|||
return result
|
||||
}
|
||||
|
||||
const transform = Effect.fn("CatalogV2.transform")(function* () {
|
||||
const draft = { records: clone(records), data: HashMap.toValues(records) }
|
||||
yield* plugin.trigger("catalog.transform", context(draft), {})
|
||||
records = draft.records
|
||||
const applyPolicy = Effect.fn("CatalogV2.applyPolicy")(function* (draft: {
|
||||
records: HashMap.HashMap<ProviderV2.ID, ProviderRecord>
|
||||
data: ProviderRecord[]
|
||||
}) {
|
||||
const ctx = context(draft)
|
||||
for (const record of [...draft.data]) {
|
||||
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
|
||||
ctx.provider.remove(record.provider.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const rebuild = Effect.fn("CatalogV2.rebuild")(function* () {
|
||||
const draft = { records: HashMap.empty<ProviderV2.ID, ProviderRecord>(), data: [] as ProviderRecord[] }
|
||||
for (const loader of loaders) loader.update(context(draft))
|
||||
yield* plugin.trigger("catalog.transform", context(draft), {})
|
||||
yield* applyPolicy(draft)
|
||||
records = draft.records
|
||||
})
|
||||
|
||||
|
|
@ -217,6 +228,7 @@ export const layer = Layer.effect(
|
|||
Effect.gen(function* () {
|
||||
const draft = { records: clone(records), data: HashMap.toValues(records) }
|
||||
yield* plugin.triggerFor(id, "catalog.transform", context(draft), {})
|
||||
yield* applyPolicy(draft)
|
||||
records = draft.records
|
||||
}),
|
||||
),
|
||||
|
|
@ -354,4 +366,7 @@ export const layer = Layer.effect(
|
|||
|
||||
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(PluginV2.defaultLayer),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Context, Effect, Layer, Option, Schema } from "effect"
|
|||
import { AppFileSystem } from "../filesystem"
|
||||
import { Global } from "../global"
|
||||
import { Location } from "../location"
|
||||
import { Policy } from "../policy"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import { ConfigV2 } from "./schema"
|
||||
|
||||
|
|
@ -24,6 +25,7 @@ export const layer = Layer.effect(
|
|||
const fs = yield* AppFileSystem.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const policy = yield* Policy.Service
|
||||
const names = ["config.json", "opencode.json", "opencode.jsonc"]
|
||||
|
||||
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
||||
|
|
@ -34,7 +36,11 @@ export const layer = Layer.effect(
|
|||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) return
|
||||
|
||||
const info = Option.getOrUndefined(Schema.decodeUnknownOption(ConfigV2.Info)(input, { errors: "all" }))
|
||||
// Accept legacy fields while v2 is migrated incrementally; recognized
|
||||
// fields still have to satisfy the v2 schema.
|
||||
const info = Option.getOrUndefined(
|
||||
Schema.decodeUnknownOption(ConfigV2.Info)(input, { errors: "all", onExcessProperty: "ignore" }),
|
||||
)
|
||||
if (!info) return
|
||||
return new ConfigV2.Loaded({ source: new ConfigV2.FileSource({ type: "file", path: filepath }), info })
|
||||
})
|
||||
|
|
@ -74,6 +80,9 @@ export const layer = Layer.effect(
|
|||
// Apply general settings first and more specific settings last:
|
||||
// global config, project files, then `.opencode` files.
|
||||
const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()]
|
||||
// Rules use the opposite order so a user-global rule can override a
|
||||
// repository rule. Statement order inside each file stays unchanged.
|
||||
yield* policy.load(configs.toReversed().flatMap((config) => config.info.policies ?? []))
|
||||
|
||||
return Service.of({
|
||||
directories: Effect.fn("Config.directories")(function* () {
|
||||
|
|
@ -86,4 +95,7 @@ export const layer = Layer.effect(
|
|||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Global.defaultLayer),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,27 @@
|
|||
export * as ConfigV2 from "./schema"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Catalog } from "../catalog"
|
||||
import { Policy as PolicyV2 } from "../policy"
|
||||
import { ConfigProvider } from "./provider"
|
||||
|
||||
// Each core domain exports the policy actions it supports. Adding an action to
|
||||
// this union makes it valid in authored config while keeping Policy generic.
|
||||
export const PolicyAction = Schema.Union([Catalog.PolicyActions])
|
||||
|
||||
export class Policy extends Schema.Class<Policy>("ConfigV2.Policy")({
|
||||
...PolicyV2.Info.fields,
|
||||
action: PolicyAction,
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigV2.Info")({
|
||||
$schema: Schema.optional(Schema.String).annotate({
|
||||
description: "JSON schema reference for configuration validation",
|
||||
}),
|
||||
shell: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Default shell to use for terminal and shell tool execution",
|
||||
}),
|
||||
policies: Policy.pipe(Schema.Array, Schema.optional),
|
||||
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,14 +2,17 @@ import { Layer, LayerMap } from "effect"
|
|||
import { Location } from "./location"
|
||||
import { Catalog } from "./catalog"
|
||||
import { PluginBoot } from "./plugin/boot"
|
||||
import { Policy } from "./policy"
|
||||
import { Config } from "./config/config"
|
||||
|
||||
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
|
||||
lookup: (ref: Location.Ref) => {
|
||||
const result = Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(
|
||||
const result = Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer, Config.defaultLayer).pipe(
|
||||
Layer.provideMerge(Policy.defaultLayer),
|
||||
Layer.provideMerge(Location.defaultLayer(ref)),
|
||||
)
|
||||
return result
|
||||
},
|
||||
idleTimeToLive: "5 minutes",
|
||||
idleTimeToLive: "60 minutes",
|
||||
dependencies: [],
|
||||
}) {}
|
||||
|
|
|
|||
44
packages/core/src/policy.ts
Normal file
44
packages/core/src/policy.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
export * as Policy from "./policy"
|
||||
|
||||
import { Context, Effect as EffectRuntime, Layer, Schema } from "effect"
|
||||
import { Wildcard } from "./util/wildcard"
|
||||
import { Location } from "./location"
|
||||
|
||||
export const Effect = Schema.Literals(["allow", "deny"]).annotate({ identifier: "Policy.Effect" })
|
||||
export type Effect = typeof Effect.Type
|
||||
|
||||
export class Info extends Schema.Class<Info>("Policy.Info")({
|
||||
action: Schema.String,
|
||||
effect: Effect,
|
||||
resource: Schema.String,
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly load: (statements: Info[]) => EffectRuntime.Effect<void>
|
||||
readonly evaluate: (action: string, resource: string, fallback: Effect) => EffectRuntime.Effect<Effect>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Policy") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
EffectRuntime.gen(function* () {
|
||||
let statements: Info[] = []
|
||||
yield* Location.Service
|
||||
|
||||
return Service.of({
|
||||
load: EffectRuntime.fn("Policy.load")(function* (input) {
|
||||
statements = input
|
||||
}),
|
||||
evaluate: EffectRuntime.fn("Policy.evaluate")(function* (action, resource, fallback) {
|
||||
return (
|
||||
statements.findLast(
|
||||
(statement) => Wildcard.match(action, statement.action) && Wildcard.match(resource, statement.resource),
|
||||
)?.effect ?? fallback
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
Loading…
Add table
Add a link
Reference in a new issue