opencode-orchestrator-uni 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -55,6 +55,7 @@ import matter from "gray-matter"
55
55
  import { registerKitCommands } from "./lib/commands"
56
56
  import { createSessionBudget, readSessionBudgetOptions, safeJson } from "./lib/session-budget"
57
57
  import { registerAuditTools } from "./lib/tools"
58
+ import { createWriteGuard } from "./lib/write-guard"
58
59
 
59
60
  /** Stable plugin id: diagnostics, enable/disable selectors, storage scope. */
60
61
  export const PLUGIN_ID = "orchestrator.uni"
@@ -419,10 +420,20 @@ async function setup(ctx: Plugin.Context): Promise<Plugin.Cleanup | undefined> {
419
420
  // tools are registered must abort that subscription too — otherwise a
420
421
  // half-registered plugin leaks an event listener for the lifetime of the server.
421
422
  const sessionBudget = await createSessionBudget(ctx, readSessionBudgetOptions(ctx.options, warn), locale, warn)
423
+ // The path-root write guard enforces the orchestrator's artifact-only edit
424
+ // contract beyond what flat `permissions` can express (see `lib/write-guard.ts`).
425
+ // It registers on the host's permission hook and stays a no-op on a host without
426
+ // that API — a missing hook is reported, never fatal. Registration happens
427
+ // before the tools so a later failure disposes the guard with the rest of the chain.
428
+ const writeGuard = await createWriteGuard(ctx, {
429
+ locationDirectory: String(ctx.location.directory),
430
+ warn,
431
+ })
422
432
  let toolsCleanup: () => void
423
433
  try {
424
434
  toolsCleanup = await registerAuditTools(ctx, { sessionBudget })
425
435
  } catch (error) {
436
+ await writeGuard.cleanup()
426
437
  await sessionBudget.cleanup()
427
438
  throw error
428
439
  }
@@ -431,6 +442,7 @@ async function setup(ctx: Plugin.Context): Promise<Plugin.Cleanup | undefined> {
431
442
  commandsCleanup = await registerKitCommands(ctx, locale)
432
443
  } catch (error) {
433
444
  toolsCleanup()
445
+ await writeGuard.cleanup()
434
446
  await sessionBudget.cleanup()
435
447
  throw error
436
448
  }
@@ -441,7 +453,11 @@ async function setup(ctx: Plugin.Context): Promise<Plugin.Cleanup | undefined> {
441
453
  try {
442
454
  toolsCleanup()
443
455
  } finally {
444
- await sessionBudget.cleanup()
456
+ try {
457
+ await writeGuard.cleanup()
458
+ } finally {
459
+ await sessionBudget.cleanup()
460
+ }
445
461
  }
446
462
  }
447
463
  }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * write-guard — path-root enforcement for the root orchestrator's edits.
3
+ *
4
+ * Flat `permissions` are a last-match-wins list, so they cannot express two
5
+ * properties at once: `reports/` is an artifact subtree (even `reports/src/x`
6
+ * must stay writable) while `src/`, `docs/` and the other source roots are
7
+ * denied at their first segment (even under an artifact-looking name such as
8
+ * `src/reports/x`). The rules also cannot tell a root artifact file
9
+ * (`state.md`) from a source file of the same name (`app/state.md`).
10
+ *
11
+ * This module implements the intended contract as a permission hook:
12
+ *
13
+ * - resolve a relative resource against the session's location directory and
14
+ * strip that prefix; a resource that stays outside the location is denied —
15
+ * the guard is the workspace boundary, so `reports/`, `docs/planning/` and
16
+ * `audit-*` are writable only inside it. The location is read from the
17
+ * session on every event (`session.get`), so a moved session is evaluated
18
+ * against its new directory; the setup location is only a fallback (used when
19
+ * the lookup is missing, fails or exceeds its 250 ms bound, with one warning);
20
+ * - normalize separators and collapse `.`/`..`, then walk the segments left to
21
+ * right and let the first root decide: `reports` → allow (the whole subtree
22
+ * is an artifact), `docs` + `planning` + a path whose last segment is `.md`
23
+ * (any depth) → allow, any source root (`src`, `lib`, `test`, `tests`,
24
+ * `agents`, `tools`, `schemas`, `eval`, `.github`) → deny. `docs` is not part
25
+ * of that set: its own branch above allows nested `docs/planning/` Markdown
26
+ * and denies every other `docs/` path;
27
+ * - with no root in the path, an artifact file (`case-*`, `risks-*`,
28
+ * `handoff-*`, `decisions-*`, `state.md`) is allowed only in the location
29
+ * root, under `reports/`, under `docs/planning/`, or inside an `audit-*`
30
+ * directory;
31
+ * - everything else is denied.
32
+ *
33
+ * The hook only tightens an `edit` evaluation for the `orchestrator` agent: it
34
+ * runs after the flat rules, so a local `edit: allow *` on the agent (a config
35
+ * or a Markdown override) cannot bypass the guard. A failure anywhere in the
36
+ * guard is fail-open — a broken guard must never break the session.
37
+ */
38
+ import type { Plugin } from "@opencode/plugin"
39
+
40
+ /** Source roots that hold project code or kit content; `docs` has its own branch in `decideSegments`. */
41
+ const SOURCE_ROOTS: ReadonlySet<string> = new Set([
42
+ "src",
43
+ "lib",
44
+ "test",
45
+ "tests",
46
+ "agents",
47
+ "tools",
48
+ "schemas",
49
+ "eval",
50
+ ".github",
51
+ ])
52
+
53
+ /** Reserved artifact subtree: everything below it is an audit/state artifact. */
54
+ const ARTIFACT_DIR = "reports"
55
+
56
+ /** Artifact files allowed only in an artifact location (see `artifactLocation`). */
57
+ const ARTIFACT_FILE = /^(?:case-.*|risks-.*|handoff-.*|decisions-.*|state)\.md$/
58
+
59
+ /** Ad-hoc audit workspace directory; any depth below it is an artifact location. */
60
+ const AUDIT_DIR = /^audit-/
61
+
62
+ const MARKDOWN_FILE = /\.md$/
63
+
64
+ /** Message attached to a denied evaluation; also shown to the model. */
65
+ export const WRITE_GUARD_MESSAGE = "root orchestrator may edit only audit/session-state artifacts"
66
+
67
+ /** Upper bound on the per-event session lookup; a hung store must not hold the edit open. */
68
+ const SESSION_LOOKUP_TIMEOUT_MS = 250
69
+
70
+ export type EditDecision = "allow" | "deny"
71
+
72
+ /** Case-fold one segment the way the host resolves paths on `platform`. */
73
+ function fold(segment: string, platform: string): string {
74
+ return platform === "win32" ? segment.toLowerCase() : segment
75
+ }
76
+
77
+ /** Split a path into segments, collapsing `.`/`..` and unifying separators. */
78
+ function normalizeSegments(input: string): string[] {
79
+ const segments: string[] = []
80
+ for (const part of input.replaceAll("\\", "/").split("/")) {
81
+ if (part === "" || part === ".") continue
82
+ if (part === "..") {
83
+ segments.pop()
84
+ continue
85
+ }
86
+ segments.push(part)
87
+ }
88
+ return segments
89
+ }
90
+
91
+ /** Whether a resource names an absolute path (POSIX root or a Windows drive). */
92
+ function isAbsolute(resource: string): boolean {
93
+ const normalized = resource.replaceAll("\\", "/")
94
+ return normalized.startsWith("/") || /^[A-Za-z]:\//.test(normalized)
95
+ }
96
+
97
+ /** Drop the location prefix when `absolute` is inside it; `undefined` otherwise. */
98
+ function stripLocation(
99
+ absolute: readonly string[],
100
+ location: readonly string[],
101
+ platform: string,
102
+ ): string[] | undefined {
103
+ if (location.length === 0 || location.length > absolute.length) return undefined
104
+ for (let index = 0; index < location.length; index += 1) {
105
+ if (fold(absolute[index]!, platform) !== fold(location[index]!, platform)) return undefined
106
+ }
107
+ return absolute.slice(location.length)
108
+ }
109
+
110
+ /** `reports/` and `docs/planning/` already allow earlier; roots and files are checked here. */
111
+ function artifactLocation(segments: readonly string[], platform: string): boolean {
112
+ if (segments.length === 1) return true
113
+ return segments.slice(0, -1).some((segment) => AUDIT_DIR.test(fold(segment, platform)))
114
+ }
115
+
116
+ function decideSegments(segments: readonly string[], platform: string): EditDecision {
117
+ for (let index = 0; index < segments.length; index += 1) {
118
+ const segment = segments[index]!
119
+ const key = fold(segment, platform)
120
+ if (key === ARTIFACT_DIR) return "allow"
121
+ // `docs` is not part of SOURCE_ROOTS: this branch allows nested planning
122
+ // Markdown and denies every other `docs/` path.
123
+ if (key === "docs") {
124
+ const next = segments[index + 1]
125
+ const last = segments.at(-1)
126
+ if (
127
+ next !== undefined &&
128
+ fold(next, platform) === "planning" &&
129
+ last !== undefined &&
130
+ MARKDOWN_FILE.test(fold(last, platform))
131
+ ) {
132
+ return "allow"
133
+ }
134
+ return "deny"
135
+ }
136
+ if (SOURCE_ROOTS.has(key)) return "deny"
137
+ }
138
+ const last = segments.at(-1)
139
+ if (last !== undefined && ARTIFACT_FILE.test(fold(last, platform)) && artifactLocation(segments, platform)) {
140
+ return "allow"
141
+ }
142
+ return "deny"
143
+ }
144
+
145
+ /**
146
+ * Decide whether the root orchestrator may write `resource` in `locationDirectory`.
147
+ *
148
+ * A relative resource is resolved against the location directory; an absolute one
149
+ * is used as is. The path is then reduced to its relative form: anything that stays
150
+ * outside the location is denied (the location is the workspace boundary), and an
151
+ * inside path is decided by its first segment.
152
+ */
153
+ export function decideEdit(
154
+ resource: string,
155
+ locationDirectory: string,
156
+ platform: string = process.platform,
157
+ ): EditDecision {
158
+ const location = normalizeSegments(locationDirectory)
159
+ const raw = resource.replaceAll("\\", "/")
160
+ const resolved = isAbsolute(raw) ? normalizeSegments(raw) : normalizeSegments(`${location.join("/")}/${raw}`)
161
+ const relative = stripLocation(resolved, location, platform)
162
+ if (relative === undefined) return "deny"
163
+ return decideSegments(relative, platform)
164
+ }
165
+
166
+ export interface WriteGuardOptions {
167
+ /** Directory the session belongs to; relative resources resolve against it. */
168
+ readonly locationDirectory: string
169
+ /** Reports a missing host API or a registration failure (the guard stays no-op). */
170
+ readonly warn: (message: string) => void
171
+ }
172
+
173
+ export interface WriteGuard {
174
+ /** `true` when the permission hook is registered. */
175
+ readonly available: boolean
176
+ /** Disposes the hook registration; safe to call when the guard is unavailable. */
177
+ readonly cleanup: () => Promise<void>
178
+ }
179
+
180
+ /** The evaluate event fields the guard reads and writes (the host type is structural). */
181
+ interface EvaluateEvent {
182
+ readonly sessionID?: string
183
+ readonly agent?: string
184
+ readonly action: string
185
+ readonly resources: readonly string[]
186
+ effect: "allow" | "deny" | "ask"
187
+ message?: string
188
+ }
189
+
190
+ /** The session surface the guard reads the current location from (structural). */
191
+ interface SessionDirectoryApi {
192
+ readonly get?: (input: { readonly sessionID: string }) => Promise<unknown>
193
+ }
194
+
195
+ /**
196
+ * Register the path-root write guard on the host's permission hook.
197
+ * Never throws: an unavailable or failing host API leaves the guard as a no-op.
198
+ */
199
+ export async function createWriteGuard(ctx: Plugin.Context, options: WriteGuardOptions): Promise<WriteGuard> {
200
+ const noop = async () => {}
201
+ const permission = (ctx as { readonly permission?: Plugin.Context["permission"] }).permission
202
+ if (permission === undefined || typeof permission.hook !== "function") {
203
+ options.warn("write guard: ctx.permission.hook is unavailable; the path-root guard is not registered")
204
+ return { available: false, cleanup: noop }
205
+ }
206
+ const session = (ctx as { readonly session?: SessionDirectoryApi }).session
207
+ // A failing, missing or hanging `session.get` falls back to the setup location;
208
+ // the warning is emitted once per guard so a persistent failure cannot flood
209
+ // the log on every edit (the single flag is deliberate — no counter, no reset).
210
+ let sessionWarned = false
211
+ const warnSessionOnce = (message: string): void => {
212
+ if (sessionWarned) return
213
+ sessionWarned = true
214
+ options.warn(message)
215
+ }
216
+ const locationFor = async (sessionID: string | undefined): Promise<string> => {
217
+ if (typeof session?.get !== "function") {
218
+ warnSessionOnce("write guard: ctx.session.get is unavailable, using the setup location")
219
+ return options.locationDirectory
220
+ }
221
+ if (sessionID === undefined) return options.locationDirectory
222
+ let timer: ReturnType<typeof setTimeout> | undefined
223
+ try {
224
+ // The lookup is bounded: a hung session store must not hold the permission
225
+ // event open (fail-open covers a rejection, not a promise that never settles).
226
+ const lookup = session.get({ sessionID })
227
+ const timeout = new Promise<never>((_, reject) => {
228
+ timer = setTimeout(() => {
229
+ reject(new Error(`timed out after ${SESSION_LOOKUP_TIMEOUT_MS} ms`))
230
+ }, SESSION_LOOKUP_TIMEOUT_MS)
231
+ })
232
+ const info = (await Promise.race([lookup, timeout])) as
233
+ | { readonly location?: { readonly directory?: unknown } }
234
+ | undefined
235
+ const directory = info?.location?.directory
236
+ if (typeof directory === "string" && directory !== "") return directory
237
+ warnSessionOnce("write guard: the session reports no location directory, using the setup location")
238
+ return options.locationDirectory
239
+ } catch (error) {
240
+ // Fail-open: an unreadable session must not break (or disable) the guard.
241
+ warnSessionOnce(
242
+ `write guard: cannot read the session directory, using the setup location: ${
243
+ error instanceof Error ? error.message : String(error)
244
+ }`,
245
+ )
246
+ return options.locationDirectory
247
+ } finally {
248
+ if (timer !== undefined) clearTimeout(timer)
249
+ }
250
+ }
251
+ try {
252
+ const registration = await permission.hook("evaluate", async (event: EvaluateEvent) => {
253
+ try {
254
+ if (event.agent !== "orchestrator" || event.action !== "edit" || event.effect === "deny") return
255
+ // The session may have been moved since setup: the host resolves the
256
+ // resource against the session's current directory, so the guard must too.
257
+ const locationDirectory = await locationFor(event.sessionID)
258
+ const denied = event.resources.some((resource) => decideEdit(resource, locationDirectory) === "deny")
259
+ if (!denied) return
260
+ event.effect = "deny"
261
+ event.message = WRITE_GUARD_MESSAGE
262
+ } catch (error) {
263
+ // Fail-open: a broken guard must never break the session it protects.
264
+ options.warn(`write guard: evaluation failed: ${error instanceof Error ? error.message : String(error)}`)
265
+ }
266
+ })
267
+ let disposed = false
268
+ return {
269
+ available: true,
270
+ cleanup: async () => {
271
+ // Idempotent: a repeated cleanup must not dispose the registration twice.
272
+ if (disposed) return
273
+ disposed = true
274
+ await registration.dispose()
275
+ },
276
+ }
277
+ } catch (error) {
278
+ options.warn(
279
+ `write guard: cannot register the permission hook: ${error instanceof Error ? error.message : String(error)}`,
280
+ )
281
+ return { available: false, cleanup: noop }
282
+ }
283
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "opencode-orchestrator-uni",
4
- "version": "0.5.0",
5
- "description": "Native OpenCode V2 plugin that registers the orchestrator agent kit (root orchestrator, audit subagents, fix-runner) and exposes the kit's process documents as the audit-kit reference.",
4
+ "version": "0.5.2",
5
+ "description": "An OpenCode V2 plugin that runs coding work as an evidence-driven audit loop: a root orchestrator, read-only auditors, one fix-runner, and deterministic verification tools.",
6
6
  "type": "module",
7
7
  "license": "MIT",
8
8
  "author": "OniVe",