opencode-orchestrator-uni 0.4.0 → 0.5.1
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/README.md +149 -14
- package/README.ru.md +151 -14
- package/agents/en/orchestrator.md +51 -5
- package/agents/ru/orchestrator.md +51 -5
- package/docs/en/call-mechanics.md +6 -4
- package/docs/en/instruction-other-session.md +6 -6
- package/docs/en/root-runbook.md +3 -1
- package/docs/en/session-discipline.md +230 -0
- package/docs/ru/call-mechanics.md +6 -4
- package/docs/ru/instruction-other-session.md +6 -6
- package/docs/ru/root-runbook.md +3 -1
- package/docs/ru/session-discipline.md +225 -0
- package/index.ts +44 -12
- package/lib/commands.ts +62 -6
- package/lib/session-budget.ts +1236 -0
- package/lib/tools.ts +145 -7
- package/lib/write-guard.ts +283 -0
- package/package.json +1 -1
package/lib/tools.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* tools — registration of the kit's deterministic, read-only audit tools.
|
|
3
3
|
*
|
|
4
|
-
* `registerAuditTools(ctx)` publishes
|
|
4
|
+
* `registerAuditTools(ctx)` publishes five tools in the `orch_uni` namespace
|
|
5
5
|
* (effective names `orch_uni_validate_report`, `orch_uni_context_lint`,
|
|
6
|
-
* `orch_uni_snapshot`, `orch_uni_sessions`) and returns
|
|
7
|
-
* background session-index subscription:
|
|
6
|
+
* `orch_uni_snapshot`, `orch_uni_sessions`, `orch_uni_session_stats`) and returns
|
|
7
|
+
* a cleanup that stops the background session-index subscription:
|
|
8
8
|
*
|
|
9
9
|
* - `validate_report` — extract the SGR JSON block of an agent report, resolve
|
|
10
10
|
* its schema, validate it and report prompt-level warnings;
|
|
@@ -14,11 +14,12 @@
|
|
|
14
14
|
* - `snapshot` — deterministic revision of a directory tree (per-file sha256 +
|
|
15
15
|
* aggregate hash) for "the state the verdict was fixed on";
|
|
16
16
|
* - `sessions` — child sessions of a session (agent, model, cost, tokens, time),
|
|
17
|
-
* read through the plugin's session API only
|
|
17
|
+
* read through the plugin's session API only;
|
|
18
|
+
* - `session_stats` — the session-protocol counters and limits of one session.
|
|
18
19
|
*
|
|
19
20
|
* Every tool answers with a JSON text body. Failures are typed (`E_*`) codes in
|
|
20
21
|
* the body rather than thrown tool errors, so an agent can branch on the code.
|
|
21
|
-
* All
|
|
22
|
+
* All five are read-only: they never write to the workspace or the config; the
|
|
22
23
|
* only state `sessions` keeps is a plugin-scoped session index in `ctx.storage`
|
|
23
24
|
* (see the `sessions` tool notes below).
|
|
24
25
|
*/
|
|
@@ -28,6 +29,7 @@ import { fileURLToPath } from "node:url"
|
|
|
28
29
|
import type { Plugin } from "@opencode/plugin"
|
|
29
30
|
import { lintContext } from "./context-lint"
|
|
30
31
|
import { checkReport, type JsonSchemaNode, type ValidationCode } from "./report-schema"
|
|
32
|
+
import { compactionLimitReached, type SessionBudgetReport } from "./session-budget"
|
|
31
33
|
import { DEFAULT_EXCLUDE, isSnapshotFailure, snapshotTree } from "./snapshot"
|
|
32
34
|
|
|
33
35
|
/** Tool namespace; effective names become `orch_uni_<name>`. */
|
|
@@ -39,6 +41,7 @@ export const TOOL_IDS: readonly string[] = [
|
|
|
39
41
|
"orch_uni_context_lint",
|
|
40
42
|
"orch_uni_snapshot",
|
|
41
43
|
"orch_uni_sessions",
|
|
44
|
+
"orch_uni_session_stats",
|
|
42
45
|
]
|
|
43
46
|
|
|
44
47
|
/** Directory holding the shipped report schemas (`schemas/*.json`). */
|
|
@@ -144,6 +147,31 @@ function asRecord(input: unknown): Record<string, unknown> | undefined {
|
|
|
144
147
|
: undefined
|
|
145
148
|
}
|
|
146
149
|
|
|
150
|
+
/**
|
|
151
|
+
* `session.get` failures that mean the session is genuinely absent.
|
|
152
|
+
*
|
|
153
|
+
* A live host may throw with an empty message but a typed `_tag`/`name`/`code`;
|
|
154
|
+
* the message regex is only a narrow fallback, so a transient `unknown database`
|
|
155
|
+
* or `unknown host` stays `E_INTERNAL` and cannot masquerade as a missing session.
|
|
156
|
+
*/
|
|
157
|
+
function isNotFoundError(error: unknown): boolean {
|
|
158
|
+
const record = asRecord(error)
|
|
159
|
+
if (record !== undefined) {
|
|
160
|
+
for (const field of ["_tag", "name", "code"] as const) {
|
|
161
|
+
const value = record[field]
|
|
162
|
+
if (
|
|
163
|
+
value === "Session.NotFoundError" ||
|
|
164
|
+
value === "SessionNotFoundError" ||
|
|
165
|
+
value === "NotFoundError" ||
|
|
166
|
+
value === "ENOENT"
|
|
167
|
+
) {
|
|
168
|
+
return true
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return /session.*not.?found|unknown session/i.test(errorMessage(error))
|
|
173
|
+
}
|
|
174
|
+
|
|
147
175
|
/** `validate_report` input contract. */
|
|
148
176
|
const VALIDATE_REPORT_INPUT = {
|
|
149
177
|
type: "object",
|
|
@@ -207,6 +235,14 @@ const SESSIONS_INPUT = {
|
|
|
207
235
|
},
|
|
208
236
|
} as const
|
|
209
237
|
|
|
238
|
+
/** `session_stats` input contract. */
|
|
239
|
+
const SESSION_STATS_INPUT = {
|
|
240
|
+
type: "object",
|
|
241
|
+
properties: {
|
|
242
|
+
sessionID: { type: "string", description: "Session to report. Default: the calling session." },
|
|
243
|
+
},
|
|
244
|
+
} as const
|
|
245
|
+
|
|
210
246
|
interface SessionInfoLike {
|
|
211
247
|
readonly id?: string
|
|
212
248
|
readonly parentID?: string | null
|
|
@@ -389,10 +425,12 @@ export function resolveRoot(directory: string, input: string): string {
|
|
|
389
425
|
return path.isAbsolute(input) ? path.normalize(input) : path.resolve(directory, input)
|
|
390
426
|
}
|
|
391
427
|
|
|
392
|
-
/** Optional seams for tests; the plugin calls this with
|
|
428
|
+
/** Optional seams for tests; the plugin calls this with the session-budget report. */
|
|
393
429
|
export interface RegisterAuditToolsOptions {
|
|
394
430
|
/** Directory to read `schemas/*.json` from. Defaults to the shipped `schemas/`. */
|
|
395
431
|
readonly schemasDirectory?: string
|
|
432
|
+
/** Session counters from the runtime session guard; absent in isolation. */
|
|
433
|
+
readonly sessionBudget?: SessionBudgetReport
|
|
396
434
|
}
|
|
397
435
|
|
|
398
436
|
/**
|
|
@@ -646,13 +684,99 @@ export async function registerAuditTools(
|
|
|
646
684
|
} catch (error) {
|
|
647
685
|
const message = error instanceof Error ? error.message : String(error)
|
|
648
686
|
if (message === "E_ABORTED" || toolContext.signal.aborted) return failure("E_ABORTED", "listing aborted")
|
|
649
|
-
if (getFn !== undefined &&
|
|
687
|
+
if (getFn !== undefined && isNotFoundError(error)) {
|
|
650
688
|
return failure("E_NOT_FOUND", `session ${parentID} was not found: ${message}`, { sessionID: parentID })
|
|
651
689
|
}
|
|
652
690
|
return failure("E_INTERNAL", message, { sessionID: parentID })
|
|
653
691
|
}
|
|
654
692
|
}
|
|
655
693
|
|
|
694
|
+
const sessionStatsTool = async (
|
|
695
|
+
input: unknown,
|
|
696
|
+
toolContext: { readonly signal: AbortSignal; readonly sessionID: string },
|
|
697
|
+
): Promise<{ content: string }> => {
|
|
698
|
+
const record = input === undefined ? {} : asRecord(input)
|
|
699
|
+
if (record === undefined) return failure("E_INPUT", "input must be an object ({sessionID?})")
|
|
700
|
+
const { sessionID } = record
|
|
701
|
+
if (sessionID !== undefined && (typeof sessionID !== "string" || sessionID === "")) {
|
|
702
|
+
return failure("E_INPUT", '"sessionID" must be a non-empty string')
|
|
703
|
+
}
|
|
704
|
+
if (toolContext.signal.aborted) return failure("E_ABORTED", "report aborted")
|
|
705
|
+
const target = (sessionID as string | undefined) ?? toolContext.sessionID
|
|
706
|
+
const budget = options.sessionBudget
|
|
707
|
+
if (budget === undefined) {
|
|
708
|
+
return failure("E_UNAVAILABLE", "the session guard is not registered in this plugin instance", {
|
|
709
|
+
sessionID: target,
|
|
710
|
+
})
|
|
711
|
+
}
|
|
712
|
+
if (!budget.available) {
|
|
713
|
+
return failure("E_UNAVAILABLE", "the session guard is unavailable: its hooks could not be registered", {
|
|
714
|
+
sessionID: target,
|
|
715
|
+
})
|
|
716
|
+
}
|
|
717
|
+
const tracked = budget.tracked(target)
|
|
718
|
+
const counters = budget.stats(target)
|
|
719
|
+
const { session: sessionConfig, toolOutput } = budget.config
|
|
720
|
+
const guard = budget.guard()
|
|
721
|
+
const getFn = sessionMethod<SessionGetFn>(session, "get")
|
|
722
|
+
if (getFn === undefined && !tracked) {
|
|
723
|
+
return failure(
|
|
724
|
+
"E_NO_API",
|
|
725
|
+
"this OpenCode build exposes no session.get() and the session is not tracked by this plugin instance",
|
|
726
|
+
{ sessionID: target },
|
|
727
|
+
)
|
|
728
|
+
}
|
|
729
|
+
let info: Record<string, unknown> | undefined
|
|
730
|
+
let getError: unknown
|
|
731
|
+
let getMissing = false
|
|
732
|
+
if (getFn !== undefined) {
|
|
733
|
+
try {
|
|
734
|
+
const fetched: SessionInfoLike | null | undefined = await getFn({ sessionID: target })
|
|
735
|
+
if (fetched === undefined || fetched === null) getMissing = true
|
|
736
|
+
else info = sessionSummary(fetched)
|
|
737
|
+
} catch (error) {
|
|
738
|
+
getError = error
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
// A session this instance never observed is only "not found" when the store
|
|
742
|
+
// says so; a transient error (lock, permission, transport) stays `E_INTERNAL`
|
|
743
|
+
// instead of masquerading as a missing session. A known session with zero
|
|
744
|
+
// requests is a valid `tracked:false` report. `session.get` returning
|
|
745
|
+
// `undefined` without an error is the store's "no such session" answer.
|
|
746
|
+
if (info === undefined && !tracked) {
|
|
747
|
+
if (getError !== undefined) {
|
|
748
|
+
const message = errorMessage(getError)
|
|
749
|
+
if (isNotFoundError(getError)) {
|
|
750
|
+
return failure("E_NOT_FOUND", `session ${target} was not found: ${message}`, { sessionID: target })
|
|
751
|
+
}
|
|
752
|
+
return failure("E_INTERNAL", message, { sessionID: target })
|
|
753
|
+
}
|
|
754
|
+
if (getMissing) {
|
|
755
|
+
return failure("E_NOT_FOUND", `session ${target} was not found`, { sessionID: target })
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
return json({
|
|
759
|
+
ok: true,
|
|
760
|
+
sessionID: target,
|
|
761
|
+
counters,
|
|
762
|
+
limits: {
|
|
763
|
+
promptTokensSoft: sessionConfig.promptTokensSoft,
|
|
764
|
+
maxCompactions: sessionConfig.maxCompactions,
|
|
765
|
+
maxToolOutputChars: toolOutput.maxChars,
|
|
766
|
+
},
|
|
767
|
+
overBudget: counters.promptTokensMax > sessionConfig.promptTokensSoft,
|
|
768
|
+
compactionLimitReached: compactionLimitReached(budget.config, counters.compactions),
|
|
769
|
+
toolsPerTurn: counters.requests > 0 ? Math.round((counters.toolCalls / counters.requests) * 100) / 100 : 0,
|
|
770
|
+
tracked,
|
|
771
|
+
guard,
|
|
772
|
+
...(getFn === undefined
|
|
773
|
+
? { session: null, limitations: ["session.get is unavailable in this build"] }
|
|
774
|
+
: info === undefined
|
|
775
|
+
? {}
|
|
776
|
+
: { session: info }),
|
|
777
|
+
})
|
|
778
|
+
}
|
|
779
|
+
|
|
656
780
|
await context.tool.transform((editor) => {
|
|
657
781
|
editor.namespace({
|
|
658
782
|
name: TOOL_NAMESPACE,
|
|
@@ -707,6 +831,20 @@ export async function registerAuditTools(
|
|
|
707
831
|
options: { namespace: TOOL_NAMESPACE, codemode: true },
|
|
708
832
|
execute: sessionsTool,
|
|
709
833
|
})
|
|
834
|
+
editor.add({
|
|
835
|
+
name: "session_stats",
|
|
836
|
+
description:
|
|
837
|
+
"Session protocol counters of a session: requests, estimated prompt tokens (last/max), budget band, confirmed " +
|
|
838
|
+
"compactions and compaction attempts, failed compactions, the live compaction source, reminders injected, tool " +
|
|
839
|
+
"calls and capped tool outputs (totals and per normalized tool name), chars kept out of context, plus the " +
|
|
840
|
+
"configured limits and toolsPerTurn. Read-only; counters belong to this plugin instance. `sessionID` may name any " +
|
|
841
|
+
"session on this server, not only a child of the caller. Returns JSON {ok, sessionID, counters, limits, overBudget, " +
|
|
842
|
+
"compactionLimitReached, toolsPerTurn, tracked, guard, session?, limitations?} or a typed code " +
|
|
843
|
+
"(E_INPUT|E_NOT_FOUND|E_INTERNAL|E_NO_API|E_UNAVAILABLE|E_ABORTED).",
|
|
844
|
+
input: SESSION_STATS_INPUT,
|
|
845
|
+
options: { namespace: TOOL_NAMESPACE, codemode: true },
|
|
846
|
+
execute: sessionStatsTool,
|
|
847
|
+
})
|
|
710
848
|
})
|
|
711
849
|
|
|
712
850
|
return () => {
|
|
@@ -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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "opencode-orchestrator-uni",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.5.1",
|
|
5
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.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|