pantheon-opencode 1.5.2 → 1.6.0-beta.10
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/.pantheon/code-mode/README.md +4 -2
- package/.pantheon/code-mode/checkpoint_session.py +138 -102
- package/.pantheon/code-mode/compress-inline.py +102 -57
- package/.pantheon/code-mode/manifest.json +2 -8
- package/AGENTS.global.md +182 -0
- package/AGENTS.md +10 -891
- package/CHANGELOG.md +343 -0
- package/README.md +577 -79
- package/README.pt-BR.md +345 -60
- package/bin/pantheon-init.mjs +26 -3
- package/commands/pantheon-audit.md +7 -1
- package/commands/pantheon-consolidate.md +1 -1
- package/commands/pantheon-deepwork.md +14 -3
- package/commands/pantheon-focus.md +17 -0
- package/commands/pantheon-optimize.md +2 -2
- package/commands/pantheon-vision.md +61 -12
- package/package.json +30 -11
- package/plugin.json +2 -2
- package/pyproject.toml +11 -16
- package/scripts/build-agents-md.mjs +376 -58
- package/scripts/changelog-merge.mjs +161 -0
- package/scripts/check-plugin-v2-coverage.mjs +213 -0
- package/scripts/ci-validate-yaml.py +7 -5
- package/scripts/{code_mode_server.py → code_mode.py} +119 -27
- package/scripts/cost.mjs +244 -41
- package/scripts/doctor.mjs +236 -29
- package/scripts/install/agents-md.mjs +4 -4
- package/scripts/install/cli-ui.mjs +1 -1
- package/scripts/install/config-migration.mjs +319 -6
- package/scripts/install/health-check.mjs +26 -37
- package/scripts/install/interactive.mjs +7 -7
- package/scripts/install/migrate.mjs +1 -1
- package/scripts/install/opencode-version.mjs +246 -4
- package/scripts/install/opencode.mjs +627 -179
- package/scripts/install/plugin.mjs +43 -9
- package/scripts/install/shared.mjs +120 -12
- package/scripts/install/vision-prompt.mjs +161 -0
- package/scripts/install-mcp.mjs +28 -9
- package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +392 -78
- package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
- package/scripts/{memory_mcp_server.py → memory_mcp.py} +372 -198
- package/scripts/postinstall.mjs +0 -7
- package/scripts/probe-context-rehydrate.mjs +36 -13
- package/scripts/probe-pantheon-cost.mjs +71 -15
- package/scripts/prune.mjs +2 -2
- package/scripts/run-node-tests.mjs +189 -0
- package/scripts/sync-artifacts.mjs +26 -13
- package/scripts/sync-tui.mjs +24 -3
- package/scripts/test-opencode-v2-sandbox.sh +1913 -0
- package/scripts/validate-routing.mjs +43 -3
- package/scripts/validate-sandbox-install.mjs +138 -0
- package/scripts/versioning.mjs +13 -1
- package/scripts/vision-status.mjs +682 -0
- package/skills-lock.json +15 -16
- package/src/agents/aphrodite.md +20 -37
- package/src/agents/apollo.md +7 -3
- package/src/agents/athena.md +63 -35
- package/src/agents/demeter.md +8 -28
- package/src/agents/gaia.md +1 -1
- package/src/agents/hephaestus.md +10 -21
- package/src/agents/hermes.md +15 -38
- package/src/agents/iris.md +1 -1
- package/src/agents/mnemosyne.md +23 -60
- package/src/agents/nyx.md +1 -1
- package/src/agents/prometheus.md +16 -20
- package/src/agents/talos.md +10 -2
- package/src/agents/themis.md +5 -5
- package/src/agents/zeus.md +99 -130
- package/src/instructions/agent-return-format.instructions.md +18 -3
- package/src/instructions/backend-standards.instructions.md +2 -0
- package/src/instructions/frontend-standards.instructions.md +3 -1
- package/src/instructions/memory-protocol.instructions.md +11 -110
- package/src/instructions/zeus-anti-stall.instructions.md +14 -61
- package/src/instructions/zeus-communication-rules.instructions.md +0 -1
- package/src/instructions/zeus-council-synthesis.instructions.md +32 -260
- package/src/instructions/zeus-memory-operations.instructions.md +26 -0
- package/src/instructions/zeus-timeout-retry.instructions.md +40 -87
- package/src/mcp/{code_mode_server.py → code_mode.py} +119 -27
- package/src/mcp/eval_store.py +2 -2
- package/src/mcp/mcp_codemap_module.py +559 -129
- package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +392 -78
- package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
- package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +373 -250
- package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +396 -40
- package/src/mcp/requirements-mcp.txt +10 -6
- package/src/mcp/requirements-vision.txt +4 -2
- package/src/pantheon/cost-command.ts +399 -101
- package/src/pantheon/delegation-enforce.ts +10 -1
- package/src/pantheon/model-command.ts +18 -7
- package/src/pantheon/presets.d.mts +0 -8
- package/src/pantheon/presets.mjs +5 -32
- package/src/pantheon/todo-enforcer.ts +1 -1
- package/src/pantheon/v2-bounded-read.ts +640 -0
- package/src/pantheon/v2-delegation-enforce.ts +214 -0
- package/src/pantheon/v2-execute-after.ts +186 -0
- package/src/pantheon/v2-tools.ts +429 -0
- package/src/pantheon/v2-unsupported.d.mts +11 -0
- package/src/pantheon/v2-unsupported.mjs +90 -0
- package/src/pantheon/v2-vision.ts +622 -0
- package/src/pantheon/vision-local.ts +531 -0
- package/src/pantheon/vision.ts +1161 -116
- package/src/plugin-v2.ts +1244 -288
- package/src/plugin.ts +12 -9
- package/src/plugins/tui/dist/server.d.ts +1 -2
- package/src/plugins/tui/dist/server.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
- package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
- package/src/plugins/tui/dist/tui-v2.js +659 -0
- package/src/plugins/tui/dist/tui-v2.js.map +1 -0
- package/src/plugins/tui/dist/tui.d.ts +85 -82
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +72 -59
- package/src/plugins/tui/index-v2.ts +6 -0
- package/src/plugins/tui/package-lock.json +5504 -1341
- package/src/plugins/tui/package.json +25 -15
- package/src/plugins/tui/src/index-v2.tsx +734 -0
- package/src/plugins/tui/src/index.tsx +106 -85
- package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
- package/src/plugins/tui/tsconfig.build.json +5 -0
- package/src/plugins/tui/tsconfig.typecheck.json +5 -0
- package/src/plugins/tui/tsdown.config.ts +22 -2
- package/src/plugins/tui/tui-v2.ts +2 -0
- package/src/plugins/tui/tui.ts +3 -0
- package/src/routing.yml +11 -22
- package/src/skills/README.md +34 -39
- package/src/skills/agent-coordination/SKILL.md +7 -2
- package/src/skills/artifact-management/SKILL.md +12 -5
- package/src/skills/auto-continue/SKILL.md +54 -95
- package/src/skills/code-review-checklist/SKILL.md +5 -5
- package/src/skills/context-compression/SKILL.md +26 -879
- package/src/skills/session-goal/SKILL.md +14 -5
- package/src/skills/spec-driven-development/SKILL.md +3 -1
- package/src/skills/tdd-with-agents/SKILL.md +18 -10
- package/.pantheon/code-mode/eval-llm-judge.py +0 -255
- package/.pantheon/code-mode/eval-monte-carlo.py +0 -368
- package/.pantheon/code-mode/eval-run.py +0 -197
- package/.pantheon/code-mode/eval-static.py +0 -321
- package/.pantheon/code-mode/session-end-save.py +0 -186
- package/.pantheon/code-mode/session-end-save.sh +0 -18
- package/opencode.json +0 -58
- package/scripts/recover-zenodo-deposition.mjs +0 -146
- package/scripts/test-opencode-v1-v2-sandbox.sh +0 -862
- package/scripts/validate-zenodo-release.mjs +0 -154
- package/scripts/zenodo-artifact.mjs +0 -94
- package/scripts/zenodo-release-state.mjs +0 -53
- package/src/skills/orchestration-workflow/SKILL.md +0 -311
package/src/plugin-v2.ts
CHANGED
|
@@ -2,68 +2,168 @@
|
|
|
2
2
|
* Pantheon V2 Plugin — full orchestration plugin for OpenCode V2.
|
|
3
3
|
*
|
|
4
4
|
* Registers:
|
|
5
|
-
* -
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* - 3 self-sufficient tools via V2 tool.transform (hashline_edit,
|
|
6
|
+
* pantheon_cost, pantheon_model) plus native `vision_describe` when the host
|
|
7
|
+
* exposes the V2 model catalog; Vision resolves OpenCode Zen auth and sends
|
|
8
|
+
* images directly to the selected multimodal model.
|
|
9
|
+
* The goal tools need V1 infrastructure and are absent from this surface.
|
|
10
|
+
* - 5 event subscriptions (session.created, idle, deleted, error, compacted)
|
|
11
|
+
* - Session hooks (prompt, context, compaction)
|
|
12
|
+
* - A tool `execute.before` hook that ENFORCES read-only sessions: the agent
|
|
13
|
+
* arrives on the host's event, a read-only agent (apollo/gaia) registers its
|
|
14
|
+
* session, and the shared `createEnforcementGuard` throws to deny
|
|
15
|
+
* `hashline_edit` / `pantheon_model` / `edit` / `write` / `bash` / `task`.
|
|
16
|
+
* See `registerV2ToolHooks`.
|
|
17
|
+
* - The SAME `execute.before` hook runs scripts/hooks/scan-secrets.sh over the
|
|
18
|
+
* tool input and throws on exit 2 (high-confidence token match). This is the
|
|
19
|
+
* ONLY hard security block on the V2 surface. It is wired HERE, not delegated
|
|
20
|
+
* to `src/plugins/pantheon-hooks.ts`: opencode.json declares only
|
|
21
|
+
* `src/plugin-v2` in `plugins`, so the V1 plugin never loads on a V2-only
|
|
22
|
+
* install and V1's identical block was inert by construction.
|
|
23
|
+
|
|
24
|
+
* - A tool `execute.after` hook and a permission hook, with permission
|
|
25
|
+
* evaluation enforcing the caller/target matrix for Pantheon agents
|
|
9
26
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
27
|
+
* Every tool registered through `draft.add` MUST declare `output`, and its
|
|
28
|
+
* `execute` MUST resolve to a value carrying `output`. The host enforces the
|
|
29
|
+
* two as a pair — see `V2ToolDraft` for the exact contract.
|
|
13
30
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
31
|
+
* ## The host domains the SDK types do not carry
|
|
32
|
+
*
|
|
33
|
+
* `@opencode-ai/plugin` 1.18.33 types `PluginContext` as exactly:
|
|
34
|
+
* options · agent · aisdk · catalog · command · integration · plugin ·
|
|
35
|
+
* reference · skill
|
|
36
|
+
* — no `tool`, `event`, `permission` or `session`. 1.18.34 (`latest`) is the
|
|
37
|
+
* same, so following the stable SDK line does not resolve this: it is a
|
|
38
|
+
* STRUCTURAL DECLARATION task, not a pin reconciliation.
|
|
39
|
+
*
|
|
40
|
+
* A live opencode 2.0.22+ host DOES have these domains, with the shapes this module
|
|
41
|
+
* uses. Measured by `tests/canary/plugin-v2-tool-canary.test.mjs`, which loads
|
|
42
|
+
* this file through the real loader and records `Object.keys(ctx)` from inside
|
|
43
|
+
* the host:
|
|
44
|
+
*
|
|
45
|
+
* agent aisdk app command event experimental generate integration location
|
|
46
|
+
* mcp model options permission plugin provider reference rpc session shell
|
|
47
|
+
* skill storage tool vcs websearch worktree
|
|
48
|
+
*
|
|
49
|
+
* ctx.tool.transform function
|
|
50
|
+
* ctx.tool.hook function
|
|
51
|
+
* ctx.tool.list function
|
|
52
|
+
* ctx.event.subscribe function
|
|
53
|
+
* ctx.permission.hook function
|
|
54
|
+
* ctx.session.hook function
|
|
55
|
+
* ctx.catalog absent
|
|
56
|
+
*
|
|
57
|
+
* `HostPluginContext` below declares exactly the minimum of that, and
|
|
58
|
+
* `hostDomains` is the SINGLE documented cast that reaches them — replacing the
|
|
59
|
+
* six repeated `as unknown as Record<string, unknown>` casts this file carried
|
|
60
|
+
* (Phases 2, 3, 4, 5, 6 and 7 — every phase except Phase 1, which reads the
|
|
61
|
+
* SDK-typed domains directly), each of which type-checked nothing at all. The
|
|
62
|
+
* per-domain definition checks stay: `registerV2Tools` still returns `false`
|
|
63
|
+
* and marks `tool-transform` when `ctx.tool.transform` is missing, and likewise
|
|
64
|
+
* for the others. The cast is not a licence to assume: the canary above is what
|
|
65
|
+
* proves the domains exist on a real host, and it fails if they stop existing.
|
|
66
|
+
*
|
|
67
|
+
* If the SDK ever exposes these host domains, replacing `hostDomains` with the real
|
|
68
|
+
* type is a one-line change. Do NOT make that change while 1.18.x lacks them.
|
|
69
|
+
*
|
|
70
|
+
* V2_UNSUPPORTED_FEATURES distinguishes adapter limitations from observed
|
|
71
|
+
* host-absent APIs; setup handles optional registrations on a best-effort basis.
|
|
16
72
|
*
|
|
17
73
|
* @module plugin-v2
|
|
18
74
|
*/
|
|
19
75
|
|
|
76
|
+
import { appendFileSync, mkdirSync } from 'node:fs'
|
|
77
|
+
import { realpath as realpathAsync } from 'node:fs/promises'
|
|
78
|
+
import path from 'node:path'
|
|
20
79
|
import { fileURLToPath } from 'node:url'
|
|
21
80
|
import type {
|
|
22
81
|
AgentDraft,
|
|
23
|
-
CatalogDraft,
|
|
24
82
|
CommandDraft,
|
|
25
83
|
PluginContext,
|
|
26
84
|
ReferenceDraft,
|
|
27
|
-
SkillDraft,
|
|
28
85
|
} from '@opencode-ai/plugin/v2/promise'
|
|
29
86
|
import { define } from '@opencode-ai/plugin/v2/promise'
|
|
87
|
+
import {
|
|
88
|
+
createEnforcementGuard,
|
|
89
|
+
readOnlyRegistry,
|
|
90
|
+
syncReadOnlySession,
|
|
91
|
+
type ToolExecuteBeforeInput,
|
|
92
|
+
type ToolExecuteBeforeOutput,
|
|
93
|
+
} from './pantheon/delegation-enforce.ts'
|
|
94
|
+
import {
|
|
95
|
+
BOUNDED_READ_TOOL,
|
|
96
|
+
createV2BoundedReadTool,
|
|
97
|
+
type V2BoundedReadOriginRegistry,
|
|
98
|
+
} from './pantheon/v2-bounded-read.ts'
|
|
30
99
|
import {
|
|
31
100
|
getV2BridgeFromContext,
|
|
32
101
|
type PantheonV2Bridge,
|
|
33
102
|
type V2ContextLike,
|
|
34
103
|
} from './pantheon/v2-bridge.ts'
|
|
104
|
+
import {
|
|
105
|
+
type AuthoritativeSession,
|
|
106
|
+
evaluateV2Delegation,
|
|
107
|
+
PANTHEON_AGENTS,
|
|
108
|
+
type V2PermissionEvent,
|
|
109
|
+
V2SubagentOriginRegistry,
|
|
110
|
+
v2ResourceTarget,
|
|
111
|
+
} from './pantheon/v2-delegation-enforce.ts'
|
|
112
|
+
import { createV2ExecuteAfter, resolveV2SandboxConfig } from './pantheon/v2-execute-after.ts'
|
|
113
|
+
import { createV2ToolDefinitions, type V2ToolResult } from './pantheon/v2-tools.ts'
|
|
114
|
+
import { V2_UNSUPPORTED_FEATURE_SEED } from './pantheon/v2-unsupported.mjs'
|
|
115
|
+
import { buildV2VisionPromptContext, type V2VisionRuntime } from './pantheon/v2-vision.ts'
|
|
116
|
+
import { type HookPayload, type HookResult, runHook } from './plugins/hook-runner.ts'
|
|
35
117
|
|
|
36
118
|
// ─── Unsupported Features Registry ───────────────────────────────────────
|
|
37
119
|
|
|
38
120
|
/**
|
|
39
|
-
* Features not
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* additional features are appended during setup if their API is missing.
|
|
121
|
+
* Features not implemented by the current Pantheon V2 adapter or absent from
|
|
122
|
+
* the observed host. A listed feature is not necessarily a host-absent API.
|
|
123
|
+
* Additional features are appended during setup when a required API is missing.
|
|
43
124
|
*/
|
|
44
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Live unsupported-feature list: the shared seed plus anything `markUnsupported`
|
|
127
|
+
* appends while this process talks to a real host.
|
|
128
|
+
*
|
|
129
|
+
* The seed lives in `src/pantheon/v2-unsupported.mjs` — a plain-`.mjs` module —
|
|
130
|
+
* so that the V2 installer and `scripts/doctor.mjs` report the SAME strings
|
|
131
|
+
* without either of them having to import TypeScript. This array stays a
|
|
132
|
+
* mutable copy rather than the shared seed itself: `markUnsupported` appends to
|
|
133
|
+
* it, and the seed must stay frozen. `getUnsupportedFeatures()` still returns
|
|
134
|
+
* this exact reference.
|
|
135
|
+
*/
|
|
136
|
+
export const V2_UNSUPPORTED_FEATURES: string[] = [...V2_UNSUPPORTED_FEATURE_SEED]
|
|
45
137
|
|
|
46
138
|
// ─── Constants ───────────────────────────────────────────────────────────
|
|
47
139
|
|
|
48
140
|
const POLICY_MARKER = '<!-- pantheon-v2-policy -->'
|
|
49
141
|
const POLICY = `${POLICY_MARKER}\nFollow Pantheon routing policy: delegate implementation work to the named specialist and do not claim work was performed without verification.`
|
|
50
142
|
|
|
143
|
+
/** 2.0.16 `system` on a session hook payload is `Array<SystemPart>`. */
|
|
144
|
+
interface SystemPart {
|
|
145
|
+
type: 'text'
|
|
146
|
+
text: string
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isTextPart(entry: unknown): entry is SystemPart {
|
|
150
|
+
return (
|
|
151
|
+
typeof entry === 'object' &&
|
|
152
|
+
entry !== null &&
|
|
153
|
+
typeof (entry as { text?: unknown }).text === 'string'
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
51
157
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* Never throws
|
|
158
|
+
* Extract text from a `system` entry. The host's own coercion accepts a bare
|
|
159
|
+
* string, but the canonical 2.0.16 shape is `{ type: 'text', text }`; the
|
|
160
|
+
* string branch is retained so the handler still works against a host that
|
|
161
|
+
* sends primitives. Never throws.
|
|
56
162
|
*/
|
|
57
163
|
function systemEntryText(entry: unknown): string | null {
|
|
58
164
|
try {
|
|
59
165
|
if (typeof entry === 'string') return entry
|
|
60
|
-
if (
|
|
61
|
-
typeof entry === 'object' &&
|
|
62
|
-
entry !== null &&
|
|
63
|
-
typeof (entry as { text?: unknown }).text === 'string'
|
|
64
|
-
) {
|
|
65
|
-
return (entry as { text: string }).text
|
|
66
|
-
}
|
|
166
|
+
if (isTextPart(entry)) return entry.text
|
|
67
167
|
return null
|
|
68
168
|
} catch {
|
|
69
169
|
return null
|
|
@@ -81,19 +181,30 @@ function hasPolicyMarker(arr: unknown[]): boolean {
|
|
|
81
181
|
}
|
|
82
182
|
}
|
|
83
183
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
184
|
+
/**
|
|
185
|
+
* Normalize a `system` value to the 2.0.16 `Array<SystemPart>` shape.
|
|
186
|
+
*
|
|
187
|
+
* `SessionContext.system` is `Array<SystemPart>` where a part is
|
|
188
|
+
* `{ type: 'text', text }`. A raw string (the whole value, or an element) is
|
|
189
|
+
* not that shape, so it is upgraded: element strings become parts, and a bare
|
|
190
|
+
* string value becomes a one-element part array. Non-array, non-string values
|
|
191
|
+
* are returned unchanged.
|
|
192
|
+
*
|
|
193
|
+
* Returns the normalized array, or `null` when `system` is neither a string
|
|
194
|
+
* nor an array (nothing to normalize).
|
|
195
|
+
*/
|
|
196
|
+
function normalizeSystemToParts(system: unknown): unknown[] | null {
|
|
197
|
+
if (typeof system === 'string') {
|
|
198
|
+
return [{ type: 'text', text: system } satisfies SystemPart]
|
|
92
199
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
200
|
+
if (!Array.isArray(system)) return null
|
|
201
|
+
for (let i = 0; i < system.length; i++) {
|
|
202
|
+
const entry = system[i]
|
|
203
|
+
if (typeof entry === 'string') {
|
|
204
|
+
system[i] = { type: 'text', text: entry } satisfies SystemPart
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return system
|
|
97
208
|
}
|
|
98
209
|
|
|
99
210
|
/**
|
|
@@ -105,6 +216,88 @@ function markUnsupported(feature: string): void {
|
|
|
105
216
|
}
|
|
106
217
|
}
|
|
107
218
|
|
|
219
|
+
// ─── Host context domains the SDK types do not carry ────────────────────
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The minimal shape of `ctx.tool` this module uses, as a live opencode 2.0.22
|
|
223
|
+
* host provides it. `transform` registers the Pantheon tools, `hook` installs
|
|
224
|
+
* the `execute.before`/`execute.after` guards, `list` is the host-built
|
|
225
|
+
* descriptor dump the canary asserts on.
|
|
226
|
+
*/
|
|
227
|
+
interface V2ToolDomain {
|
|
228
|
+
transform?: (cb: (draft: V2ToolDraft) => void) => Promise<unknown>
|
|
229
|
+
hook?: (name: string, handler: (event: unknown) => void | Promise<void>) => Promise<unknown>
|
|
230
|
+
list?: () => Promise<unknown>
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The minimal shape of `ctx.event`: one async-iterable subscription. */
|
|
234
|
+
interface V2EventDomain {
|
|
235
|
+
subscribe?: (opts?: { signal?: AbortSignal }) => AsyncIterable<V2SessionEvent>
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The minimal shape of `ctx.permission`.
|
|
240
|
+
*
|
|
241
|
+
* The V2.0.25 live contract supplies an `evaluate` event with `resources`;
|
|
242
|
+
* runtime validation remains defensive because SDK declarations omit this
|
|
243
|
+
* domain and the host may evolve independently.
|
|
244
|
+
*/
|
|
245
|
+
interface V2PermissionDomain {
|
|
246
|
+
assert?: (input: Record<string, unknown>) => Promise<unknown>
|
|
247
|
+
hook?: (
|
|
248
|
+
name: string,
|
|
249
|
+
handler: (event: unknown, output?: unknown) => void | Promise<void>,
|
|
250
|
+
) => Promise<unknown>
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The minimal shape of `ctx.session` used by this module. `get` follows the V2
|
|
255
|
+
* Promise API's object input (`{ sessionID }`); session hooks accept any string
|
|
256
|
+
* and silently drop an unknown one, which is why the host-backed hook canary
|
|
257
|
+
* exists.
|
|
258
|
+
*/
|
|
259
|
+
interface V2SessionDomain {
|
|
260
|
+
hook?: (name: string, handler: (event: unknown) => void | Promise<void>) => Promise<unknown>
|
|
261
|
+
get?: (input: { sessionID: string }) => Promise<unknown>
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
interface V2LocationDomain {
|
|
265
|
+
directory?: string
|
|
266
|
+
project?: { id?: string }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
interface V2ModelDomain {
|
|
270
|
+
list?: V2VisionRuntime['model']['list']
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
interface V2GenerateDomain {
|
|
274
|
+
text?: (input: { model: { providerID: string; id: string }; prompt: string }) => Promise<unknown>
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** `PluginContext` plus the host domains 1.18.x omits. See the module header. */
|
|
278
|
+
interface HostPluginContext extends PluginContext {
|
|
279
|
+
tool?: V2ToolDomain
|
|
280
|
+
event?: V2EventDomain
|
|
281
|
+
permission?: V2PermissionDomain
|
|
282
|
+
session?: V2SessionDomain
|
|
283
|
+
model?: V2ModelDomain
|
|
284
|
+
generate?: V2GenerateDomain
|
|
285
|
+
location?: V2LocationDomain
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* THE cast to the host's context. Every optional member stays optional and every
|
|
290
|
+
* caller re-checks before use, so this narrows nothing away: it replaces five
|
|
291
|
+
* anonymous `Record<string, unknown>` casts with one named, documented,
|
|
292
|
+
* type-checked boundary.
|
|
293
|
+
*
|
|
294
|
+
* When the SDK ships these four domains, delete this function and use
|
|
295
|
+
* `PluginContext` directly.
|
|
296
|
+
*/
|
|
297
|
+
function hostDomains(context: PluginContext): HostPluginContext {
|
|
298
|
+
return context as HostPluginContext
|
|
299
|
+
}
|
|
300
|
+
|
|
108
301
|
// ─── V1 Bridge Integration ──────────────────────────────────────────────
|
|
109
302
|
|
|
110
303
|
/**
|
|
@@ -191,16 +384,6 @@ function transformAgents(draft: AgentDraft): void {
|
|
|
191
384
|
}
|
|
192
385
|
}
|
|
193
386
|
|
|
194
|
-
function transformCatalog(draft: CatalogDraft, options: PluginContext['options']): void {
|
|
195
|
-
const configured = options.default_model
|
|
196
|
-
if (typeof configured !== 'string') return
|
|
197
|
-
const separator = configured.indexOf('/')
|
|
198
|
-
if (separator <= 0 || separator === configured.length - 1) return
|
|
199
|
-
const providerID = configured.slice(0, separator)
|
|
200
|
-
const modelID = configured.slice(separator + 1)
|
|
201
|
-
if (draft.model.get(providerID, modelID)) draft.model.default.set(providerID, modelID)
|
|
202
|
-
}
|
|
203
|
-
|
|
204
387
|
function transformCommands(draft: CommandDraft): void {
|
|
205
388
|
// Beta 19192 guard (issue #92): draft may not have `list`, or list()
|
|
206
389
|
// may return a non-iterable. Never throw — mark unsupported and return.
|
|
@@ -237,41 +420,6 @@ function transformCommands(draft: CommandDraft): void {
|
|
|
237
420
|
}
|
|
238
421
|
}
|
|
239
422
|
|
|
240
|
-
function transformSkills(draft: SkillDraft): void {
|
|
241
|
-
// Beta 19192 guard (issue #92): draft may not have `list`, or list()
|
|
242
|
-
// may return a non-iterable. Never throw — mark unsupported and return.
|
|
243
|
-
let sources: Array<{ type: string; path: string }>
|
|
244
|
-
try {
|
|
245
|
-
const maybeList = (draft as unknown as { list?: unknown }).list
|
|
246
|
-
if (typeof maybeList !== 'function') {
|
|
247
|
-
markUnsupported('skill-transform-list')
|
|
248
|
-
return
|
|
249
|
-
}
|
|
250
|
-
const result = (maybeList as (this: unknown) => unknown).call(draft)
|
|
251
|
-
if (
|
|
252
|
-
result == null ||
|
|
253
|
-
typeof (result as { [Symbol.iterator]?: unknown })[Symbol.iterator] !== 'function'
|
|
254
|
-
) {
|
|
255
|
-
markUnsupported('skill-transform-list')
|
|
256
|
-
return
|
|
257
|
-
}
|
|
258
|
-
sources = Array.isArray(result)
|
|
259
|
-
? (result as Array<{ type: string; path: string }>)
|
|
260
|
-
: Array.from(result as Iterable<{ type: string; path: string }>)
|
|
261
|
-
} catch {
|
|
262
|
-
markUnsupported('skill-transform-list')
|
|
263
|
-
return
|
|
264
|
-
}
|
|
265
|
-
try {
|
|
266
|
-
const path = fileURLToPath(new URL('./skills', import.meta.url))
|
|
267
|
-
if (!sources.some((source) => source.type === 'directory' && source.path === path)) {
|
|
268
|
-
draft.source({ type: 'directory', path })
|
|
269
|
-
}
|
|
270
|
-
} catch {
|
|
271
|
-
markUnsupported('skill-transform')
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
423
|
function transformReferences(draft: ReferenceDraft): void {
|
|
276
424
|
draft.add('pantheon-agents', {
|
|
277
425
|
type: 'local',
|
|
@@ -287,32 +435,53 @@ function transformReferences(draft: ReferenceDraft): void {
|
|
|
287
435
|
*
|
|
288
436
|
* The V2 tool.transform API (when available) provides:
|
|
289
437
|
* draft.namespace({ name: "pantheon", description: "..." })
|
|
290
|
-
* draft.add({ name, description, input, execute })
|
|
438
|
+
* draft.add({ name, description, input, output, execute })
|
|
439
|
+
*
|
|
440
|
+
* `output` is part of the required shape, not an optional extra: see
|
|
441
|
+
* `V2ToolDraft` for the host contract that makes it mandatory.
|
|
291
442
|
*
|
|
292
443
|
* Returns true if tools were registered, false if the API is unavailable.
|
|
293
444
|
*/
|
|
294
|
-
async function registerV2Tools(
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
445
|
+
async function registerV2Tools(
|
|
446
|
+
context: PluginContext,
|
|
447
|
+
boundedRead?: ReturnType<typeof createV2BoundedReadTool>['definition'],
|
|
448
|
+
): Promise<boolean> {
|
|
449
|
+
const host = hostDomains(context)
|
|
450
|
+
const toolCtx = host.tool
|
|
298
451
|
|
|
299
452
|
if (!toolCtx?.transform) {
|
|
300
453
|
return false
|
|
301
454
|
}
|
|
302
455
|
|
|
303
456
|
try {
|
|
304
|
-
// Tool definitions
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
const
|
|
457
|
+
// Tool definitions carry their own `output` declaration; the draft requires
|
|
458
|
+
// it, and the host rejects a definition whose result shape cannot satisfy
|
|
459
|
+
// the declaration it makes.
|
|
460
|
+
const model = host.model
|
|
461
|
+
const visionReady = typeof model?.list === 'function'
|
|
462
|
+
if (!visionReady) markUnsupported('vision-tool')
|
|
463
|
+
const visionRuntime = visionReady ? ({ model } as V2VisionRuntime) : undefined
|
|
464
|
+
const toolDefs = createV2ToolDefinitions(visionRuntime)
|
|
465
|
+
if (boundedRead !== undefined) toolDefs.push(boundedRead)
|
|
308
466
|
|
|
309
467
|
await toolCtx.transform((draft: V2ToolDraft) => {
|
|
468
|
+
// Catalog grouping only. The host computes a tool's registration id as
|
|
469
|
+
// `options.namespace === undefined ? name : `${namespace}_${name}``, and
|
|
470
|
+
// `draft.namespace()` does NOT set `options.namespace` on later `add`
|
|
471
|
+
// calls — so the tools stay `hashline_edit` / `pantheon_cost` /
|
|
472
|
+
// `pantheon_model`, not `pantheon_*`. That bare name is what the
|
|
473
|
+
// enforcement guard's blocked-tool list matches on; see
|
|
474
|
+
// `adaptV2ExecuteBeforeEvent`.
|
|
310
475
|
draft.namespace({ name: 'pantheon', description: 'Pantheon orchestration tools' })
|
|
311
476
|
for (const def of toolDefs) {
|
|
312
477
|
draft.add({
|
|
313
478
|
name: def.name,
|
|
314
479
|
description: def.description,
|
|
315
480
|
input: def.input,
|
|
481
|
+
// Required by the host contract: without this the tool fails on
|
|
482
|
+
// every call with "Tool result declared output without an output
|
|
483
|
+
// schema". Must stay paired with def.execute resolving to `output`.
|
|
484
|
+
output: def.output,
|
|
316
485
|
execute: def.execute,
|
|
317
486
|
})
|
|
318
487
|
}
|
|
@@ -333,10 +502,11 @@ async function registerV2Tools(context: PluginContext): Promise<boolean> {
|
|
|
333
502
|
*
|
|
334
503
|
* Returns a cleanup function, or undefined if the API is unavailable.
|
|
335
504
|
*/
|
|
336
|
-
function subscribeV2Events(
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
505
|
+
function subscribeV2Events(
|
|
506
|
+
context: PluginContext,
|
|
507
|
+
onTurnEnd?: (sessionID: string) => void,
|
|
508
|
+
): (() => void) | undefined {
|
|
509
|
+
const eventCtx = hostDomains(context).event
|
|
340
510
|
|
|
341
511
|
if (!eventCtx?.subscribe) {
|
|
342
512
|
return undefined
|
|
@@ -348,6 +518,19 @@ function subscribeV2Events(context: PluginContext): (() => void) | undefined {
|
|
|
348
518
|
// biome-ignore lint/style/noNonNullAssertion: subscribe guaranteed by guard above
|
|
349
519
|
for await (const event of eventCtx.subscribe!({ signal: controller.signal })) {
|
|
350
520
|
await handleV2SessionEvent(event)
|
|
521
|
+
if (event.type === 'session.idle' || event.type === 'session.deleted') {
|
|
522
|
+
const properties = event.properties ?? {}
|
|
523
|
+
const info = properties.info
|
|
524
|
+
const infoID =
|
|
525
|
+
info !== null && typeof info === 'object' ? (info as { id?: unknown }).id : undefined
|
|
526
|
+
const sessionID =
|
|
527
|
+
typeof properties.sessionID === 'string'
|
|
528
|
+
? properties.sessionID
|
|
529
|
+
: typeof infoID === 'string'
|
|
530
|
+
? infoID
|
|
531
|
+
: undefined
|
|
532
|
+
if (sessionID) onTurnEnd?.(sessionID)
|
|
533
|
+
}
|
|
351
534
|
}
|
|
352
535
|
} catch (err: unknown) {
|
|
353
536
|
if (err instanceof Error && err.name === 'AbortError') return
|
|
@@ -370,6 +553,9 @@ async function handleV2SessionEvent(event: V2SessionEvent): Promise<void> {
|
|
|
370
553
|
case 'session.idle':
|
|
371
554
|
await onSessionIdle(event)
|
|
372
555
|
break
|
|
556
|
+
case 'session.deleted':
|
|
557
|
+
await onSessionDeleted(event)
|
|
558
|
+
break
|
|
373
559
|
case 'session.error':
|
|
374
560
|
await onSessionError(event)
|
|
375
561
|
break
|
|
@@ -381,20 +567,68 @@ async function handleV2SessionEvent(event: V2SessionEvent): Promise<void> {
|
|
|
381
567
|
}
|
|
382
568
|
}
|
|
383
569
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
570
|
+
/**
|
|
571
|
+
* Record the v2.0.29 lifecycle event in the opt-in F4A canary. Runtime events
|
|
572
|
+
* use a `{ id, created, type, data }` envelope; the child data carries
|
|
573
|
+
* `sessionID`, `parentID` and `agent`. Admission itself does not depend on this
|
|
574
|
+
* subscription: it checks the current permission source and `ctx.session.get`
|
|
575
|
+
* synchronously, so resumed roots and children use the same path.
|
|
576
|
+
*/
|
|
577
|
+
async function onSessionCreated(event: V2SessionEvent): Promise<void> {
|
|
578
|
+
const payload = event as V2SessionEvent & Record<string, unknown>
|
|
579
|
+
const properties = event.properties ?? {}
|
|
580
|
+
const data =
|
|
581
|
+
event.data !== null && typeof event.data === 'object'
|
|
582
|
+
? (event.data as Record<string, unknown>)
|
|
583
|
+
: {}
|
|
584
|
+
const info = properties.info ?? data.info
|
|
585
|
+
const sessionInfo =
|
|
586
|
+
info !== null && typeof info === 'object' ? (info as Record<string, unknown>) : {}
|
|
587
|
+
writeV2AdmissionProof('session.created', {
|
|
588
|
+
eventKeys: Object.keys(payload),
|
|
589
|
+
propertyKeys: Object.keys(properties),
|
|
590
|
+
dataKeys: Object.keys(data),
|
|
591
|
+
infoKeys: Object.keys(sessionInfo),
|
|
592
|
+
id:
|
|
593
|
+
sessionInfo.id ??
|
|
594
|
+
sessionInfo.sessionID ??
|
|
595
|
+
data.id ??
|
|
596
|
+
data.sessionID ??
|
|
597
|
+
properties.id ??
|
|
598
|
+
properties.sessionID ??
|
|
599
|
+
payload.sessionID ??
|
|
600
|
+
null,
|
|
601
|
+
parentID: sessionInfo.parentID ?? data.parentID ?? properties.parentID ?? null,
|
|
602
|
+
agent: sessionInfo.agent ?? data.agent ?? properties.agent ?? null,
|
|
603
|
+
})
|
|
388
604
|
const _bridge = resolveBridge()
|
|
389
605
|
if (_bridge?.board != null) {
|
|
390
|
-
// Bridge
|
|
391
|
-
//
|
|
606
|
+
// Bridge present ⇒ this process also loaded V1, whose own event hook owns
|
|
607
|
+
// root-session registration. Intentionally nothing to do on the V2 side.
|
|
392
608
|
}
|
|
393
609
|
}
|
|
394
610
|
|
|
395
611
|
async function onSessionIdle(_event: V2SessionEvent): Promise<void> {
|
|
396
|
-
//
|
|
397
|
-
//
|
|
612
|
+
// Temp-image sweep on the V2-only path: DELIBERATELY DEFERRED.
|
|
613
|
+
//
|
|
614
|
+
// V1 unlinks a session's temp images on `session.idle`/`session.deleted`
|
|
615
|
+
// (`cleanupSessionTempImages`, grace-guarded and refcounted). V2 cannot reuse
|
|
616
|
+
// it: `saveDataUrlImageSync` (the V2 materialization path) receives no session
|
|
617
|
+
// id, so there is no per-session `path → createdAt` map to sweep, and the
|
|
618
|
+
// content-hash temp dir is refcounted ONLY on the V1 path. The one reusable
|
|
619
|
+
// piece, `enforceTempFileCap`, is already invoked off every V2 save
|
|
620
|
+
// (`saveDataUrlImageSync`), so disk usage is bounded without an idle pass —
|
|
621
|
+
// re-running it here would be redundant, and a GLOBAL age-based unlink fired
|
|
622
|
+
// by one session's idle could delete a file another live session still
|
|
623
|
+
// references. Delivery is also best-effort: with no `ctx.event.subscribe`
|
|
624
|
+
// the stream is unsupported (`markUnsupported('event-stream')`) and no idle
|
|
625
|
+
// ever arrives. Implementing this safely needs V2-side session threading
|
|
626
|
+
// first; until then it stays deferred rather than approximated.
|
|
627
|
+
//
|
|
628
|
+
// Idle continuation — goal loop / todo enforcer dispatch. This one really
|
|
629
|
+
// does delegate: `bridge.todoEnforcer` is the V1 enforcer instance, reached
|
|
630
|
+
// only in a process that also loaded V1. On a V2-only install the bridge
|
|
631
|
+
// resolves to null and idle continuation does not run.
|
|
398
632
|
const sessionID = _event.properties?.sessionID as string | undefined
|
|
399
633
|
if (!sessionID) return
|
|
400
634
|
const bridge = resolveBridge()
|
|
@@ -407,23 +641,75 @@ async function onSessionIdle(_event: V2SessionEvent): Promise<void> {
|
|
|
407
641
|
}
|
|
408
642
|
}
|
|
409
643
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
644
|
+
/**
|
|
645
|
+
* Session-deleted handler: drop the read-only registration.
|
|
646
|
+
*
|
|
647
|
+
* `readOnlyRegistry` is a process-wide singleton and the `execute.before` hook
|
|
648
|
+
* populates it on every delegated `apollo`/`gaia` call. V1 prunes it on
|
|
649
|
+
* `session.deleted` (`src/plugin.ts`); V2 did not, so on a long-lived host
|
|
650
|
+
* every finished investigation session stayed registered for the life of the
|
|
651
|
+
* process — a slow leak, and a stale entry that would keep denying tools if
|
|
652
|
+
* the id were ever reused.
|
|
653
|
+
*
|
|
654
|
+
* This mirrors V1: unregister the session, which drops its agent entry with it
|
|
655
|
+
* (the registry stores one `ReadOnlyEntry` per session id). V1 also clears a
|
|
656
|
+
* `sessionAgents` map here; V2 has no such map and needs no equivalent, since
|
|
657
|
+
* it reads the active agent off the event instead of caching it.
|
|
658
|
+
*
|
|
659
|
+
* The id is read from `properties.sessionID` — the field the sibling
|
|
660
|
+
* `session.idle` handler already uses — falling back to V1's
|
|
661
|
+
* `properties.info.id`. Both spellings are accepted because the V2 SDK types
|
|
662
|
+
* `session.deleted` as `{ sessionID, info }` and a miss here is a silent leak,
|
|
663
|
+
* which is precisely the bug this handler exists to close.
|
|
664
|
+
*
|
|
665
|
+
* A payload with NEITHER spelling is fail-open, and unavoidably so: unregistering
|
|
666
|
+
* is what needs the id, so an id that cannot be resolved cannot be cleaned up
|
|
667
|
+
* and the entry survives. The docstring used to leave that as a documented
|
|
668
|
+
* silent branch; it now warns on the way out, because the only other symptom of
|
|
669
|
+
* a renamed field is a registry that grows for the life of the process.
|
|
670
|
+
*/
|
|
671
|
+
async function onSessionDeleted(event: V2SessionEvent): Promise<void> {
|
|
672
|
+
const properties = event.properties
|
|
673
|
+
if (properties === undefined) return
|
|
674
|
+
const info = properties.info
|
|
675
|
+
const sessionID =
|
|
676
|
+
typeof properties.sessionID === 'string'
|
|
677
|
+
? properties.sessionID
|
|
678
|
+
: info !== null && typeof info === 'object'
|
|
679
|
+
? (info as { id?: unknown }).id
|
|
680
|
+
: undefined
|
|
681
|
+
if (typeof sessionID !== 'string' || sessionID === '') {
|
|
682
|
+
// Fail-open by necessity, loud by choice: the entry for this session (if
|
|
683
|
+
// any) cannot be identified, so it stays registered. Same `console.warn`
|
|
684
|
+
// channel as the event-subscription error above — this module has no
|
|
685
|
+
// logger, and a diagnostic nobody reads on stderr is still a silent leak.
|
|
686
|
+
console.warn(
|
|
687
|
+
'[Pantheon V2] session.deleted carried no session id ' +
|
|
688
|
+
'(neither properties.sessionID nor properties.info.id); ' +
|
|
689
|
+
'its read-only registration cannot be pruned and will persist.',
|
|
690
|
+
)
|
|
691
|
+
return
|
|
416
692
|
}
|
|
693
|
+
readOnlyRegistry.unregister(sessionID)
|
|
417
694
|
}
|
|
418
695
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
}
|
|
696
|
+
/**
|
|
697
|
+
* Session-error handler: no V2-side implementation.
|
|
698
|
+
*
|
|
699
|
+
* The board error transition lives in V1's event hook, which is not loaded on
|
|
700
|
+
* a V2-only install. There is nothing to do here — deliberately, rather than
|
|
701
|
+
* describing behaviour this handler does not have.
|
|
702
|
+
*/
|
|
703
|
+
async function onSessionError(_event: V2SessionEvent): Promise<void> {}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Session-compacted handler: no V2-side implementation.
|
|
707
|
+
*
|
|
708
|
+
* Todo preservation and compaction context build live in the V1 path
|
|
709
|
+
* (`todo-preserve.ts`, driven by V1's compaction hook), which is not loaded on
|
|
710
|
+
* a V2-only install.
|
|
711
|
+
*/
|
|
712
|
+
async function onSessionCompacted(_event: V2SessionEvent): Promise<void> {}
|
|
427
713
|
|
|
428
714
|
// ─── V2 Session Hooks ───────────────────────────────────────────────────
|
|
429
715
|
|
|
@@ -437,11 +723,7 @@ async function onSessionCompacted(_event: V2SessionEvent): Promise<void> {
|
|
|
437
723
|
* Returns true if any hooks were registered.
|
|
438
724
|
*/
|
|
439
725
|
async function registerV2SessionHooks(context: PluginContext): Promise<boolean> {
|
|
440
|
-
const sessionCtx = (context
|
|
441
|
-
| {
|
|
442
|
-
hook?: (name: string, handler: (event: unknown) => void | Promise<void>) => Promise<unknown>
|
|
443
|
-
}
|
|
444
|
-
| undefined
|
|
726
|
+
const sessionCtx = hostDomains(context).session
|
|
445
727
|
|
|
446
728
|
if (!sessionCtx?.hook) {
|
|
447
729
|
return false
|
|
@@ -453,21 +735,32 @@ async function registerV2SessionHooks(context: PluginContext): Promise<boolean>
|
|
|
453
735
|
// "context" hook — inject routing policy + compaction state
|
|
454
736
|
await sessionCtx.hook('context', (event: unknown) => {
|
|
455
737
|
try {
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
if (!hasPolicyMarker(rawSystem)) {
|
|
467
|
-
rawSystem.push(toSystemEntry(POLICY, usesObjectShape(rawSystem)))
|
|
738
|
+
// Opt-in lifetime proof for the host-backed hook canary (MODE=real).
|
|
739
|
+
// Inert unless PANTHEON_HOOK_CANARY_LIFETIME_PROOF is set.
|
|
740
|
+
if (process.env.PANTHEON_HOOK_CANARY_LIFETIME_PROOF) {
|
|
741
|
+
try {
|
|
742
|
+
appendFileSync(
|
|
743
|
+
process.env.PANTHEON_HOOK_CANARY_LIFETIME_PROOF,
|
|
744
|
+
'plugin-v2:session.hook:context\n',
|
|
745
|
+
)
|
|
746
|
+
} catch {
|
|
747
|
+
// Best-effort instrumentation.
|
|
468
748
|
}
|
|
469
749
|
}
|
|
470
|
-
//
|
|
750
|
+
// 2.0.16 payload is `{ system: Array<SystemPart> }`. Normalize any
|
|
751
|
+
// string entries to `{ type: 'text', text }` parts and append the
|
|
752
|
+
// policy as a part. Junk entries are ignored; a non-array,
|
|
753
|
+
// non-string system is left untouched. Never throws (fail-open).
|
|
754
|
+
const ctx = event as { system?: unknown } | null | undefined
|
|
755
|
+
if (ctx == null) return
|
|
756
|
+
const normalized = normalizeSystemToParts(ctx.system)
|
|
757
|
+
if (normalized == null) return
|
|
758
|
+
// A bare string is replaced by its part array; an array is mutated in
|
|
759
|
+
// place (the host may read the original reference).
|
|
760
|
+
if (typeof ctx.system === 'string') ctx.system = normalized
|
|
761
|
+
if (!hasPolicyMarker(normalized)) {
|
|
762
|
+
normalized.push({ type: 'text', text: POLICY } satisfies SystemPart)
|
|
763
|
+
}
|
|
471
764
|
} catch {
|
|
472
765
|
// Fail-open: never throw to the host.
|
|
473
766
|
}
|
|
@@ -478,10 +771,31 @@ async function registerV2SessionHooks(context: PluginContext): Promise<boolean>
|
|
|
478
771
|
}
|
|
479
772
|
|
|
480
773
|
try {
|
|
481
|
-
// "prompt" hook —
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
774
|
+
// "prompt" hook — preserve attached image references and add model-visible
|
|
775
|
+
// instructions so Zeus can inspect visual context and pass it to the
|
|
776
|
+
// implementation specialist before editing.
|
|
777
|
+
await sessionCtx.hook('prompt', (event: unknown) => {
|
|
778
|
+
try {
|
|
779
|
+
// 2.0.29 host payload: `{ sessionID, messageID, prompt: { text,
|
|
780
|
+
// files, agents, skills }, metadata, delivery }`. The session id is
|
|
781
|
+
// top-level (verified against the host's `SessionPrompt.prepare`), so a
|
|
782
|
+
// data: attachment's panic-free moment also relays it to the MCP.
|
|
783
|
+
const admission = event as {
|
|
784
|
+
sessionID?: unknown
|
|
785
|
+
prompt?: { text?: unknown; files?: unknown }
|
|
786
|
+
} | null
|
|
787
|
+
const prompt = admission?.prompt
|
|
788
|
+
if (!prompt || typeof prompt.text !== 'string') return
|
|
789
|
+
if (prompt.text.includes('<!-- pantheon-v2-visual-context -->')) return
|
|
790
|
+
const sessionID =
|
|
791
|
+
typeof admission?.sessionID === 'string' && admission.sessionID.trim() !== ''
|
|
792
|
+
? admission.sessionID
|
|
793
|
+
: undefined
|
|
794
|
+
const visualContext = buildV2VisionPromptContext(prompt.files, prompt.text, sessionID)
|
|
795
|
+
if (visualContext) prompt.text = `${prompt.text}\n\n${visualContext}`
|
|
796
|
+
} catch {
|
|
797
|
+
// Fail-open: malformed/unsupported prompt shapes must not block input.
|
|
798
|
+
}
|
|
485
799
|
})
|
|
486
800
|
registered = true
|
|
487
801
|
} catch {
|
|
@@ -491,8 +805,395 @@ async function registerV2SessionHooks(context: PluginContext): Promise<boolean>
|
|
|
491
805
|
return registered
|
|
492
806
|
}
|
|
493
807
|
|
|
808
|
+
// ─── V2 Secret Scan ─────────────────────────────────────────────────────
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* High-confidence secret token formats — MIRRORS `SECRET_MASK_RE` in
|
|
812
|
+
* src/plugins/pantheon-hooks.ts (V1) and `HIGH_CONFIDENCE_PATTERNS` in
|
|
813
|
+
* scripts/hooks/scan-secrets.sh.
|
|
814
|
+
*
|
|
815
|
+
* Duplicated rather than imported because the V1 module is a separate plugin
|
|
816
|
+
* that is NOT loaded on a V2-only install (see the module header), and pulling
|
|
817
|
+
* it in for one regex would resurrect exactly the V1-hook coupling V2 exists to
|
|
818
|
+
* remove. The drift risk is real and is asserted by a test in
|
|
819
|
+
* tests/pantheon/plugin-v2-contract.test.ts.
|
|
820
|
+
*
|
|
821
|
+
* Applied to every log line this module writes, so a hook trail can never
|
|
822
|
+
* contain a raw credential. Header/KEY names (the Bifrost header alone,
|
|
823
|
+
* `api_key=...`) are deliberately NOT matched: they are not secret values.
|
|
824
|
+
*/
|
|
825
|
+
const SECRET_MASK_RE =
|
|
826
|
+
/(sk-bf-[A-Za-z0-9_-]{8,}|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9_]{36,}|glpat-[A-Za-z0-9_-]{20}|sk-[a-zA-Z0-9]{20,}|sk_live_[a-zA-Z0-9]{20,}|sk_test_[a-zA-Z0-9]{20,}|xox[baprs]-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24}|bearer\s+[a-zA-Z0-9_.-]{20,}|eyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*)/gi
|
|
827
|
+
|
|
828
|
+
/** Replace every high-confidence secret match with '****'. */
|
|
829
|
+
function maskSecret(text: string): string {
|
|
830
|
+
return text.replace(SECRET_MASK_RE, '****')
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Message thrown when scan-secrets.sh exits 2 (a high-confidence token match).
|
|
835
|
+
*
|
|
836
|
+
* This is the only deliberate throw on the V2 surface. opencode's
|
|
837
|
+
* Plugin.trigger has no catch around hook invocations, so the rejection
|
|
838
|
+
* propagates and fails the tool's execute promise BEFORE the tool body runs —
|
|
839
|
+
* the same hard block V1 has (src/plugins/pantheon-hooks.ts
|
|
840
|
+
* `SECRET_BLOCK_MESSAGE`), which was inert on a V2-only install because
|
|
841
|
+
* pantheon-hooks.ts is never loaded there (opencode.json `plugins` declares only
|
|
842
|
+
* `src/plugin-v2`).
|
|
843
|
+
*
|
|
844
|
+
* English, like every other message this module emits: this string surfaces in
|
|
845
|
+
* the user's session as the tool error, so it is read by whoever hit the block,
|
|
846
|
+
* not by the code that raised it. A Portuguese release note elsewhere in the
|
|
847
|
+
* repo is not a reason for the runtime message to be Portuguese too.
|
|
848
|
+
*/
|
|
849
|
+
const SECRET_BLOCK_MESSAGE =
|
|
850
|
+
'[plugin-v2] Blocked: high-confidence secret detected in tool input — see .pantheon/logs/hooks.log'
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Message thrown when the secret scanner cannot produce a trustworthy verdict —
|
|
854
|
+
* the script is missing/unexecutable, the child timed out or was killed, the
|
|
855
|
+
* payload could not be serialized, the report was malformed, or the exit status
|
|
856
|
+
* was unexpected.
|
|
857
|
+
*
|
|
858
|
+
* This is a deliberate fail-CLOSED denial. The previous handler blocked only
|
|
859
|
+
* `code === 2` and treated every other outcome as advisory — but the runner
|
|
860
|
+
* reports a missing script, a spawn failure and a payload-serialization failure
|
|
861
|
+
* as `code: 1` too (src/plugins/hook-runner.ts), so a broken scanner read as a
|
|
862
|
+
* clean advisory and the tool ran, silently disabling the only hard secret block
|
|
863
|
+
* on the V2 surface.
|
|
864
|
+
*
|
|
865
|
+
* English and user-facing, like {@link SECRET_BLOCK_MESSAGE}. It carries no
|
|
866
|
+
* scanner output on purpose: a scan that failed mid-flight may still be holding
|
|
867
|
+
* the raw payload, and the denial must not become the leak it exists to prevent.
|
|
868
|
+
*/
|
|
869
|
+
const SECRET_SCAN_UNAVAILABLE_MESSAGE =
|
|
870
|
+
'[plugin-v2] Blocked: the secret scanner could not complete, so this tool call was denied (fail-closed) — see .pantheon/logs/hooks.log'
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Append one masked, ISO-stamped line to .pantheon/logs/hooks.log under the
|
|
874
|
+
* working directory, and return whether it landed.
|
|
875
|
+
*
|
|
876
|
+
* V1's `reportFailure` also writes to `client.app.log` and shows a TUI toast.
|
|
877
|
+
* The V2 `PluginContext` exposes NEITHER (it is
|
|
878
|
+
* `{options, agent, aisdk, catalog, command, integration, plugin, reference,
|
|
879
|
+
* skill}` — no `client`, no `directory`), so this module has only the file
|
|
880
|
+
* channel. That is a real reduction in observability versus V1, stated here
|
|
881
|
+
* rather than implied. Best-effort by contract: a logging failure must never
|
|
882
|
+
* take down the session.
|
|
883
|
+
*/
|
|
884
|
+
function appendV2HookLog(line: string): boolean {
|
|
885
|
+
const masked = maskSecret(line)
|
|
886
|
+
if (masked === '') return false
|
|
887
|
+
const stamp = new Date().toISOString()
|
|
888
|
+
// Multi-line scanner stderr would otherwise produce continuation lines with
|
|
889
|
+
// no timestamp (V1's P0-5 fix) — one message becomes N timestamped lines.
|
|
890
|
+
const body = masked
|
|
891
|
+
.split('\n')
|
|
892
|
+
.map((part) => part.trim())
|
|
893
|
+
.filter((part) => part !== '')
|
|
894
|
+
.map((part) => `[${stamp}] ${part}`)
|
|
895
|
+
.join('\n')
|
|
896
|
+
if (body === '') return false
|
|
897
|
+
try {
|
|
898
|
+
const dir = path.join(process.cwd(), '.pantheon', 'logs')
|
|
899
|
+
mkdirSync(dir, { recursive: true })
|
|
900
|
+
appendFileSync(path.join(dir, 'hooks.log'), `${body}\n`, 'utf8')
|
|
901
|
+
return true
|
|
902
|
+
} catch {
|
|
903
|
+
return false
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// ─── BENCH Hook Timing (beta-10 latency slice, Phase 0) ────────────────
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* BENCH INSTRUMENTATION — observation only, zero behavior change.
|
|
911
|
+
*
|
|
912
|
+
* The beta-10 latency slice measures the V2 tool hook chain. These helpers
|
|
913
|
+
* record the start/end of `execute.before` and `execute.after` per tool call
|
|
914
|
+
* and append one single-line `[bench]` entry per hook end to the EXISTING
|
|
915
|
+
* hooks.log channel (same appendV2HookLog path as every other line: masked,
|
|
916
|
+
* stamped, best-effort, never throwing). The lines are consumed by
|
|
917
|
+
* bench/lib/hooklog.mjs.
|
|
918
|
+
*
|
|
919
|
+
* Deliberate properties:
|
|
920
|
+
* - the log call happens in `finally`, so denied calls (read-only guard,
|
|
921
|
+
* secret block) are timed too, and the denial still propagates unchanged
|
|
922
|
+
* - the join key is the host callID (`event.id`); a TTL-bounded map prunes
|
|
923
|
+
* stale entries so a background/never-after call cannot leak memory
|
|
924
|
+
* - `since_before_ms=unavailable` when no before record exists — the bench
|
|
925
|
+
* contract never lets "unavailable" be encoded as 0
|
|
926
|
+
*/
|
|
927
|
+
|
|
928
|
+
/** Entries older than this are pruned (a task tool result may never fire after). */
|
|
929
|
+
const BENCH_TIMING_TTL_MS = 5 * 60_000
|
|
930
|
+
/** Hard cap — mirrors the V1 delegationStarts bound. */
|
|
931
|
+
const BENCH_TIMING_MAX = 256
|
|
932
|
+
|
|
933
|
+
const benchTimings = new Map<string, number>()
|
|
934
|
+
|
|
935
|
+
function pruneBenchTimings(now: number): void {
|
|
936
|
+
for (const [callID, startedAt] of benchTimings) {
|
|
937
|
+
if (now - startedAt > BENCH_TIMING_TTL_MS) benchTimings.delete(callID)
|
|
938
|
+
}
|
|
939
|
+
while (benchTimings.size >= BENCH_TIMING_MAX) {
|
|
940
|
+
let oldestID: string | null = null
|
|
941
|
+
let oldestAt = Number.POSITIVE_INFINITY
|
|
942
|
+
for (const [id, at] of benchTimings) {
|
|
943
|
+
if (at < oldestAt) {
|
|
944
|
+
oldestAt = at
|
|
945
|
+
oldestID = id
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
if (oldestID === null) break
|
|
949
|
+
benchTimings.delete(oldestID)
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/** Read `{tool, id}` off a V2 tool event without assuming its exact shape. */
|
|
954
|
+
function benchEventIdentity(event: unknown): { tool: string; callID: string } {
|
|
955
|
+
if (event === null || typeof event !== 'object') return { tool: '', callID: '' }
|
|
956
|
+
const payload = event as { tool?: unknown; id?: unknown }
|
|
957
|
+
return {
|
|
958
|
+
tool: typeof payload.tool === 'string' ? payload.tool : '',
|
|
959
|
+
callID: typeof payload.id === 'string' ? payload.id : '',
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Append one `[bench]` timing line for a finished hook phase. Best-effort and
|
|
965
|
+
* exception-free by construction (appendV2HookLog never throws).
|
|
966
|
+
*/
|
|
967
|
+
function logBenchHookTiming(phase: 'before' | 'after', event: unknown, startedAt: number): void {
|
|
968
|
+
try {
|
|
969
|
+
const { tool, callID } = benchEventIdentity(event)
|
|
970
|
+
const endedAt = Date.now()
|
|
971
|
+
if (phase === 'before') {
|
|
972
|
+
if (callID !== '') {
|
|
973
|
+
benchTimings.set(callID, startedAt)
|
|
974
|
+
pruneBenchTimings(startedAt)
|
|
975
|
+
}
|
|
976
|
+
appendV2HookLog(
|
|
977
|
+
`[bench] tool.execute.before end tool=${tool} callID=${callID} ` +
|
|
978
|
+
`started_at=${new Date(startedAt).toISOString()} duration_ms=${endedAt - startedAt}`,
|
|
979
|
+
)
|
|
980
|
+
return
|
|
981
|
+
}
|
|
982
|
+
const beforeAt = callID !== '' ? benchTimings.get(callID) : undefined
|
|
983
|
+
if (beforeAt !== undefined) benchTimings.delete(callID)
|
|
984
|
+
const sinceBefore = beforeAt === undefined ? 'unavailable' : String(endedAt - beforeAt)
|
|
985
|
+
appendV2HookLog(
|
|
986
|
+
`[bench] tool.execute.after end tool=${tool} callID=${callID} ` +
|
|
987
|
+
`duration_ms=${endedAt - startedAt} since_before_ms=${sinceBefore}`,
|
|
988
|
+
)
|
|
989
|
+
} catch {
|
|
990
|
+
// Instrumentation must never affect the hook chain.
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// ─── V2 Secret-Scan Verdict ─────────────────────────────────────────────
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* The scanner's own report marker, written to stderr on every non-clean run
|
|
998
|
+
* (exit 1 advisory, exit 2 block). It is the discriminator between a genuine
|
|
999
|
+
* low-confidence advisory and a runner infrastructure failure, BOTH of which
|
|
1000
|
+
* surface as `code === 1`: the runner's own failures (missing/unexecutable
|
|
1001
|
+
* script, spawn error, payload-serialization error) resolve a structured
|
|
1002
|
+
* `HookResult` with `code: 1` and no scan report, while the real scanner's
|
|
1003
|
+
* advisory always carries this marker. Requiring it means a scanner that stops
|
|
1004
|
+
* reporting fails toward a denial, never toward a silent allow.
|
|
1005
|
+
*/
|
|
1006
|
+
const SECRET_SCAN_MARKER = '[SECRET SCAN]'
|
|
1007
|
+
|
|
1008
|
+
/** Sanitized failure classes — logged in place of raw scanner output. */
|
|
1009
|
+
type SecretScanFailureReason =
|
|
1010
|
+
| 'scanner-timeout'
|
|
1011
|
+
| 'scanner-signal'
|
|
1012
|
+
| 'scanner-spawn-failure'
|
|
1013
|
+
| 'scanner-serialization-failure'
|
|
1014
|
+
| 'scanner-malformed-output'
|
|
1015
|
+
| 'scanner-unexpected-exit'
|
|
1016
|
+
|
|
1017
|
+
type SecretScanDecision =
|
|
1018
|
+
| { action: 'allow' }
|
|
1019
|
+
| { action: 'advisory' }
|
|
1020
|
+
| { action: 'block' }
|
|
1021
|
+
| { action: 'fail-closed'; reason: SecretScanFailureReason }
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* The runner's own infrastructure-failure messages (see hook-runner.ts), matched
|
|
1025
|
+
* at the start of stderr where the runner is the only writer. Matching a prefix
|
|
1026
|
+
* rather than a substring keeps a scan report that merely happens to quote one
|
|
1027
|
+
* of these phrases from flipping a valid advisory into a denial.
|
|
1028
|
+
*/
|
|
1029
|
+
const SCANNER_INFRA_FAILURES: ReadonlyArray<[string, SecretScanFailureReason]> = [
|
|
1030
|
+
['payload serialization failed:', 'scanner-serialization-failure'],
|
|
1031
|
+
['spawn failed:', 'scanner-spawn-failure'],
|
|
1032
|
+
['hook script failed to spawn:', 'scanner-spawn-failure'],
|
|
1033
|
+
]
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Classify one secret-scan {@link HookResult} into an enforcement decision.
|
|
1037
|
+
*
|
|
1038
|
+
* fail-closed by construction: `allow` and `advisory` are reachable only from a
|
|
1039
|
+
* result that is unambiguously a completed scanner run. Everything else —
|
|
1040
|
+
* timeout, signal, spawn/serialization failure, missing report, unknown or
|
|
1041
|
+
* unexpected exit status — denies. `advisory` additionally requires the
|
|
1042
|
+
* scanner's own report marker, which is what separates an exit-1 advisory from
|
|
1043
|
+
* the runner's exit-1 infrastructure failure.
|
|
1044
|
+
*
|
|
1045
|
+
* `block` covers the high-confidence match (exit 2); the caller turns it into
|
|
1046
|
+
* the user-facing block.
|
|
1047
|
+
*/
|
|
1048
|
+
function evaluateSecretScanResult(scan: HookResult): SecretScanDecision {
|
|
1049
|
+
if (scan.timedOut) return { action: 'fail-closed', reason: 'scanner-timeout' }
|
|
1050
|
+
if (scan.signal !== null) return { action: 'fail-closed', reason: 'scanner-signal' }
|
|
1051
|
+
if (!Number.isInteger(scan.code)) {
|
|
1052
|
+
return { action: 'fail-closed', reason: 'scanner-malformed-output' }
|
|
1053
|
+
}
|
|
1054
|
+
for (const [signature, reason] of SCANNER_INFRA_FAILURES) {
|
|
1055
|
+
if (scan.stderr.startsWith(signature)) return { action: 'fail-closed', reason }
|
|
1056
|
+
}
|
|
1057
|
+
if (scan.code === 0) return { action: 'allow' }
|
|
1058
|
+
if (scan.code === 2) return { action: 'block' }
|
|
1059
|
+
if (scan.code === 1) {
|
|
1060
|
+
return scan.stderr.includes(SECRET_SCAN_MARKER)
|
|
1061
|
+
? { action: 'advisory' }
|
|
1062
|
+
: { action: 'fail-closed', reason: 'scanner-malformed-output' }
|
|
1063
|
+
}
|
|
1064
|
+
return { action: 'fail-closed', reason: 'scanner-unexpected-exit' }
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/** Invocation of scripts/hooks/scan-secrets.sh used by the tool guard. */
|
|
1068
|
+
type SecretScanRunner = (payload: HookPayload) => Promise<HookResult>
|
|
1069
|
+
|
|
1070
|
+
const defaultSecretScanRunner: SecretScanRunner = (payload) => runHook('scan-secrets.sh', payload)
|
|
1071
|
+
|
|
1072
|
+
let secretScanRunner: SecretScanRunner = defaultSecretScanRunner
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* Test seam — override (or, with `null`, restore) the V2 secret-scan runner.
|
|
1076
|
+
*
|
|
1077
|
+
* Exported only for tests/pantheon/plugin-v2-contract.test.ts, which injects
|
|
1078
|
+
* structured {@link HookResult}s (missing scanner, timeout, malformed output)
|
|
1079
|
+
* that the real script cannot be made to produce on demand. There is no
|
|
1080
|
+
* production caller; the shipped handler always uses `defaultSecretScanRunner`.
|
|
1081
|
+
*/
|
|
1082
|
+
export function setV2SecretScanRunnerForTests(runner: SecretScanRunner | null): void {
|
|
1083
|
+
secretScanRunner = runner ?? defaultSecretScanRunner
|
|
1084
|
+
}
|
|
1085
|
+
|
|
494
1086
|
// ─── V2 Tool Hooks ──────────────────────────────────────────────────────
|
|
495
1087
|
|
|
1088
|
+
/**
|
|
1089
|
+
* The `tool.execute.before` event as the host dispatches it (opencode 2.0.22).
|
|
1090
|
+
*
|
|
1091
|
+
* Transcribed from the host's tool service:
|
|
1092
|
+
*
|
|
1093
|
+
* trigger("tool", "execute.before", { tool, sessionID, agent, messageID, id, input })
|
|
1094
|
+
*
|
|
1095
|
+
* Two shape differences from V1's positional `(input, output)` pair, both
|
|
1096
|
+
* handled by {@link adaptV2ExecuteBeforeEvent}: the host passes ONE payload
|
|
1097
|
+
* object, and the tool arguments live on `input` where V1 called them
|
|
1098
|
+
* `output.args`.
|
|
1099
|
+
*
|
|
1100
|
+
* `agent` is a required field of the host's tool context
|
|
1101
|
+
* (`ToolInvocation.context = { sessionID, agent, messageID, id }`), so the
|
|
1102
|
+
* active agent arrives on the very event the enforcement guard needs. V1 had to
|
|
1103
|
+
* learn it from a separate `chat.params` hook and keep a session→agent map; V2
|
|
1104
|
+
* does not have to, and neither does this module.
|
|
1105
|
+
*/
|
|
1106
|
+
interface V2ToolExecuteBeforeEvent {
|
|
1107
|
+
tool?: unknown
|
|
1108
|
+
sessionID?: unknown
|
|
1109
|
+
messageID?: unknown
|
|
1110
|
+
agent?: unknown
|
|
1111
|
+
id?: unknown
|
|
1112
|
+
input?: unknown
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Read-only enforcement for the V2 tool surface.
|
|
1117
|
+
*
|
|
1118
|
+
* The same factory, the same `DEFAULT_BLOCKED_TOOLS` and the same read-only set
|
|
1119
|
+
* that V1 uses, so the two surfaces cannot drift on what counts as a mutating
|
|
1120
|
+
* tool — `hashline_edit` and `pantheon_model` are denied here for exactly the
|
|
1121
|
+
* reason they are denied in V1.
|
|
1122
|
+
*
|
|
1123
|
+
* Deliberately NOT passed `getSessionAgent` / `isRootSession` / `isChildSession`.
|
|
1124
|
+
* There are TWO distinct reasons, and conflating them is what produced a wrong
|
|
1125
|
+
* causal chain here once already — keep them apart:
|
|
1126
|
+
*
|
|
1127
|
+
* 1. MISSING `getSessionAgent` (wiring). This is what would deny every
|
|
1128
|
+
* `task()` call. V2 learns the active agent from the `execute.before` event
|
|
1129
|
+
* itself and keeps no session→agent map, so there is no lookup to hand the
|
|
1130
|
+
* guard. The guard would call `isDelegationAllowed(undefined, target)`,
|
|
1131
|
+
* which returns `false`, and throw "caller agent is unavailable" — for
|
|
1132
|
+
* every caller, including Zeus. Note what is NOT happening: an unseeded
|
|
1133
|
+
* `isRootSession` would *pass* the root gate (`isRoot` reports `true` for an
|
|
1134
|
+
* unknown session), so the hierarchy is not what denies here.
|
|
1135
|
+
*
|
|
1136
|
+
* 2. UNWIRED HIERARCHY (design). `SessionHierarchyRegistry` is what gates
|
|
1137
|
+
* exactly two things, and nothing else: the depth-2 child deny
|
|
1138
|
+
* (`isChildSession`) and the root-session gate (`isRootSession`). Left
|
|
1139
|
+
* unseeded, `isChild` is `false` for every session, so depth-2 would never
|
|
1140
|
+
* fire on a genuine child — a hierarchy that cannot tell a child from a root
|
|
1141
|
+
* is not a trustworthy input to either check.
|
|
1142
|
+
*
|
|
1143
|
+
* "Unwired", not "impossible": the V2 `session.created` event carries
|
|
1144
|
+
* `properties.info: Session` including `parentID?`, the same field V1 seeds
|
|
1145
|
+
* from on the same event, so one line in `onSessionCreated` would seed live
|
|
1146
|
+
* sessions (see its docstring). What has no V2 equivalent is V1's SECOND
|
|
1147
|
+
* source, the `client.session.list()` startup seed — V2's `PluginContext`
|
|
1148
|
+
* exposes no `client`. Whether a live 2.0.22 host populates `info.parentID`
|
|
1149
|
+
* is UNVERIFIED; no canary reads an event payload. So the correct summary is
|
|
1150
|
+
* "seeded for nothing", not "unseedable", and the guard's omission is right
|
|
1151
|
+
* today for the weaker reason that nothing seeds it at all.
|
|
1152
|
+
*
|
|
1153
|
+
* Omitting all three skips the branch wholesale: the guard only enters it when
|
|
1154
|
+
* `options?.isRootSession !== undefined`. `task` is still denied inside a
|
|
1155
|
+
* read-only session via the blocked-tool list, so depth-2 holds for
|
|
1156
|
+
* apollo/gaia; the caller/target matrix itself is NOT enforced on V2. That is
|
|
1157
|
+
* a known gap, not a covered case.
|
|
1158
|
+
*/
|
|
1159
|
+
const v2EnforcementGuard = createEnforcementGuard({
|
|
1160
|
+
getReadOnlySessions: () => readOnlyRegistry.sessionIDs(),
|
|
1161
|
+
})
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* Project a V2 `execute.before` event onto the two-argument shape
|
|
1165
|
+
* `createEnforcementGuard` was built for.
|
|
1166
|
+
*
|
|
1167
|
+
* `tool` is the host's registration id, computed as
|
|
1168
|
+
* `options.namespace === undefined ? name : `${namespace}_${name}``. Pantheon
|
|
1169
|
+
* declares a namespace for catalog grouping but sets `options.namespace` on no
|
|
1170
|
+
* tool, so the id is the bare name (`hashline_edit`) and the blocked-tool match
|
|
1171
|
+
* fires. A tool that opted into `options.namespace` would gain a prefix and stop
|
|
1172
|
+
* matching — the integration test in tests/pantheon/plugin-v2-contract.test.ts
|
|
1173
|
+
* drives the real registration path and asserts on the registered names, so
|
|
1174
|
+
* that drift fails a test instead of silently disabling enforcement.
|
|
1175
|
+
*
|
|
1176
|
+
* A missing `sessionID` maps to the empty key: the registry lookup then matches
|
|
1177
|
+
* the event's own (absent) identity, so a blocked tool in a read-only session is
|
|
1178
|
+
* still denied. Fail-closed on the one axis that matters.
|
|
1179
|
+
*/
|
|
1180
|
+
function adaptV2ExecuteBeforeEvent(event: unknown): {
|
|
1181
|
+
input: ToolExecuteBeforeInput
|
|
1182
|
+
output: ToolExecuteBeforeOutput
|
|
1183
|
+
agent: unknown
|
|
1184
|
+
} {
|
|
1185
|
+
const payload = (event ?? {}) as V2ToolExecuteBeforeEvent
|
|
1186
|
+
return {
|
|
1187
|
+
input: {
|
|
1188
|
+
tool: typeof payload.tool === 'string' ? payload.tool : '',
|
|
1189
|
+
sessionID: typeof payload.sessionID === 'string' ? payload.sessionID : '',
|
|
1190
|
+
callID: typeof payload.id === 'string' ? payload.id : '',
|
|
1191
|
+
},
|
|
1192
|
+
output: { args: payload.input },
|
|
1193
|
+
agent: payload.agent,
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
496
1197
|
/**
|
|
497
1198
|
* Register tool execution hooks via V2 ctx.tool.hook.
|
|
498
1199
|
*
|
|
@@ -502,43 +1203,142 @@ async function registerV2SessionHooks(context: PluginContext): Promise<boolean>
|
|
|
502
1203
|
*
|
|
503
1204
|
* Returns true if any hooks were registered.
|
|
504
1205
|
*/
|
|
505
|
-
async function registerV2ToolHooks(
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
1206
|
+
async function registerV2ToolHooks(
|
|
1207
|
+
context: PluginContext,
|
|
1208
|
+
subagentOrigins: V2SubagentOriginRegistry,
|
|
1209
|
+
boundedReadOrigins?: V2BoundedReadOriginRegistry,
|
|
1210
|
+
): Promise<{ registered: boolean; beforeRegistered: boolean }> {
|
|
1211
|
+
const toolCtx = hostDomains(context).tool
|
|
511
1212
|
|
|
512
1213
|
if (!toolCtx?.hook) {
|
|
513
|
-
return false
|
|
1214
|
+
return { registered: false, beforeRegistered: false }
|
|
514
1215
|
}
|
|
515
1216
|
|
|
516
1217
|
let registered = false
|
|
1218
|
+
let beforeRegistered = false
|
|
517
1219
|
|
|
518
1220
|
try {
|
|
519
|
-
// "execute.before" — read-only enforcement
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
1221
|
+
// "execute.before" — read-only enforcement. Throwing from this handler
|
|
1222
|
+
// DENIES the tool call and the host surfaces the message to the session,
|
|
1223
|
+
// which is how the guard blocks a mutating tool in a read-only session.
|
|
1224
|
+
//
|
|
1225
|
+
// This is the enforcement point for the V2 surface, not a delegation to
|
|
1226
|
+
// V1: `src/plugin.ts` is not loaded when only `plugin-v2` is configured
|
|
1227
|
+
// (opencode.json `plugins`), so the V1 `tool.execute.before` hook does not
|
|
1228
|
+
// exist in that process. The guard below is instantiated here and consulted
|
|
1229
|
+
// here; without it the tools registered by registerV2Tools would be a live
|
|
1230
|
+
// write path in every session, including read-only ones.
|
|
1231
|
+
await toolCtx.hook('execute.before', async (event: unknown) => {
|
|
1232
|
+
const benchStartedAt = Date.now()
|
|
1233
|
+
const { input, output, agent } = adaptV2ExecuteBeforeEvent(event)
|
|
1234
|
+
try {
|
|
1235
|
+
// V2's equivalent of V1's `chat.params` trigger: the host puts the active
|
|
1236
|
+
// agent on the event itself, so registering here needs no separate hook
|
|
1237
|
+
// and no session→agent map. A non-read-only (or absent) agent revokes the
|
|
1238
|
+
// registration, which also covers an in-session agent switch.
|
|
1239
|
+
syncReadOnlySession(readOnlyRegistry, input.sessionID, agent)
|
|
1240
|
+
// Deliberately OUTSIDE the try/catch below: a read-only denial must keep
|
|
1241
|
+
// propagating to the host, not be swallowed as a scanner failure.
|
|
1242
|
+
await v2EnforcementGuard(input, output)
|
|
1243
|
+
|
|
1244
|
+
// High-confidence secret scan (scripts/hooks/scan-secrets.sh). This is the
|
|
1245
|
+
// ONLY hard block on the V2 surface, and it exists here — rather than via
|
|
1246
|
+
// `src/plugins/pantheon-hooks.ts` — because that module is a separate
|
|
1247
|
+
// plugin that is never loaded on a V2-only install: opencode.json
|
|
1248
|
+
// `plugins` declares only `src/plugin-v2`. V1's scan-secrets block was
|
|
1249
|
+
// therefore inert by construction, not by misconfiguration.
|
|
1250
|
+
//
|
|
1251
|
+
// exit 0 → allow. exit 1 with a valid scanner report → advisory, logged
|
|
1252
|
+
// only. exit 2 (high-confidence match) → deny. A scanner that does NOT
|
|
1253
|
+
// produce a trustworthy verdict — missing/unexecutable script, timeout,
|
|
1254
|
+
// killed child, spawn/serialization failure, malformed or unexpected
|
|
1255
|
+
// output — is fail-CLOSED: this pending tool call is denied, the session
|
|
1256
|
+
// is not. (runHook resolves a structured result instead of rejecting, so
|
|
1257
|
+
// the exit-code-only reading below cannot lean on an exception.)
|
|
1258
|
+
let blockError: Error | null = null
|
|
1259
|
+
try {
|
|
1260
|
+
const payload: HookPayload = {
|
|
1261
|
+
tool_name: input.tool,
|
|
1262
|
+
tool_input: output.args ?? {},
|
|
1263
|
+
agent_id: typeof agent === 'string' ? agent : '',
|
|
1264
|
+
session_id: input.sessionID,
|
|
1265
|
+
}
|
|
1266
|
+
const scan = await secretScanRunner(payload)
|
|
1267
|
+
const decision = evaluateSecretScanResult(scan)
|
|
1268
|
+
if (decision.action === 'block') {
|
|
1269
|
+
appendV2HookLog(SECRET_BLOCK_MESSAGE)
|
|
1270
|
+
appendV2HookLog(scan.stderr)
|
|
1271
|
+
blockError = new Error(SECRET_BLOCK_MESSAGE)
|
|
1272
|
+
} else if (decision.action === 'advisory') {
|
|
1273
|
+
// Advisory: log only, never block.
|
|
1274
|
+
appendV2HookLog(scan.stderr)
|
|
1275
|
+
} else if (decision.action === 'fail-closed') {
|
|
1276
|
+
// Sanitized failure category only — never the scanner's raw stderr,
|
|
1277
|
+
// which on a failure path is unverified and may carry the very
|
|
1278
|
+
// payload the scan exists to keep out of logs.
|
|
1279
|
+
appendV2HookLog(`${SECRET_SCAN_UNAVAILABLE_MESSAGE} (${decision.reason})`)
|
|
1280
|
+
blockError = new Error(SECRET_SCAN_UNAVAILABLE_MESSAGE)
|
|
1281
|
+
}
|
|
1282
|
+
} catch {
|
|
1283
|
+
// The runner is contracted to resolve a structured result, so this only
|
|
1284
|
+
// guards an unexpected throw (including a future change to the runner).
|
|
1285
|
+
// A scanner that cannot run is fail-CLOSED: deny this call rather than
|
|
1286
|
+
// let it through with no scan at all.
|
|
1287
|
+
appendV2HookLog(SECRET_SCAN_UNAVAILABLE_MESSAGE)
|
|
1288
|
+
blockError = new Error(SECRET_SCAN_UNAVAILABLE_MESSAGE)
|
|
1289
|
+
}
|
|
1290
|
+
if (blockError !== null) throw blockError
|
|
1291
|
+
const boundedReadOrigin = boundedReadOrigins?.capture(event)
|
|
1292
|
+
const eventTool = (event as V2ToolExecuteBeforeEvent | null)?.tool
|
|
1293
|
+
if (eventTool === BOUNDED_READ_TOOL && boundedReadOrigin === undefined) {
|
|
1294
|
+
throw new Error('BOUND_READ_UNAUTHORIZED')
|
|
1295
|
+
}
|
|
1296
|
+
if (boundedReadOrigin !== undefined) {
|
|
1297
|
+
writeV2AdmissionProof('tool.execute.before bounded-read', {
|
|
1298
|
+
sessionID: boundedReadOrigin.sessionID,
|
|
1299
|
+
messageID: boundedReadOrigin.messageID,
|
|
1300
|
+
callID: boundedReadOrigin.callID,
|
|
1301
|
+
agent: boundedReadOrigin.agent,
|
|
1302
|
+
})
|
|
1303
|
+
}
|
|
1304
|
+
const origin = subagentOrigins.capture(event)
|
|
1305
|
+
if (origin !== undefined) {
|
|
1306
|
+
writeV2AdmissionProof('tool.execute.before', { ...origin })
|
|
1307
|
+
}
|
|
1308
|
+
} finally {
|
|
1309
|
+
// BENCH instrumentation — observation only; runs on the deny paths too.
|
|
1310
|
+
logBenchHookTiming('before', event, benchStartedAt)
|
|
528
1311
|
}
|
|
529
1312
|
})
|
|
530
1313
|
registered = true
|
|
1314
|
+
beforeRegistered = true
|
|
531
1315
|
} catch {
|
|
532
1316
|
// Tool before-hook not supported
|
|
533
1317
|
}
|
|
534
1318
|
|
|
535
1319
|
try {
|
|
536
|
-
// "execute.after" —
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
1320
|
+
// "execute.after" — the V1 parity chain (task-result-guard →
|
|
1321
|
+
// context-sandbox → read-enhancer) rebuilt on the V2 event shape.
|
|
1322
|
+
//
|
|
1323
|
+
// This IS behaviour, not a registration point: on a V2-only install the V1
|
|
1324
|
+
// `pantheon-hooks` plugin is not loaded, so this handler is the only thing
|
|
1325
|
+
// that truncates oversized output and tags `read` with hashline refs.
|
|
1326
|
+
// The sandbox config comes from `ctx.options.context_sandbox` (the V2 copy
|
|
1327
|
+
// the installer writes — V2.0.25 drops the V1 top-level key), resolved with
|
|
1328
|
+
// the same resolver V1 uses.
|
|
1329
|
+
const v2ExecuteAfter = createV2ExecuteAfter({
|
|
1330
|
+
sandbox: resolveV2SandboxConfig(context.options),
|
|
1331
|
+
})
|
|
1332
|
+
await toolCtx.hook('execute.after', async (event: unknown) => {
|
|
1333
|
+
const benchStartedAt = Date.now()
|
|
1334
|
+
try {
|
|
1335
|
+
subagentOrigins.forget(event)
|
|
1336
|
+
boundedReadOrigins?.forget(event)
|
|
1337
|
+
await v2ExecuteAfter(event)
|
|
1338
|
+
} finally {
|
|
1339
|
+
// BENCH instrumentation — observation only; the awaited call means the
|
|
1340
|
+
// duration covers the whole parity chain, not just its dispatch.
|
|
1341
|
+
logBenchHookTiming('after', event, benchStartedAt)
|
|
542
1342
|
}
|
|
543
1343
|
})
|
|
544
1344
|
registered = true
|
|
@@ -546,7 +1346,7 @@ async function registerV2ToolHooks(context: PluginContext): Promise<boolean> {
|
|
|
546
1346
|
// Tool after-hook not supported
|
|
547
1347
|
}
|
|
548
1348
|
|
|
549
|
-
return registered
|
|
1349
|
+
return { registered, beforeRegistered }
|
|
550
1350
|
}
|
|
551
1351
|
|
|
552
1352
|
// ─── V2 Permission Hook ─────────────────────────────────────────────────
|
|
@@ -555,16 +1355,18 @@ async function registerV2ToolHooks(context: PluginContext): Promise<boolean> {
|
|
|
555
1355
|
* Register permission hook via V2 ctx.permission.hook.
|
|
556
1356
|
*
|
|
557
1357
|
* The V2 permission.hook API (when available) provides:
|
|
558
|
-
* ctx.permission.hook("evaluate", handler) —
|
|
1358
|
+
* ctx.permission.hook("evaluate", handler) — host permission evaluation
|
|
1359
|
+
* The handler receives one mutable PermissionEvaluation event; decisions are
|
|
1360
|
+
* written to event.effect/event.message (there is no separate V1-style output).
|
|
559
1361
|
*
|
|
560
1362
|
* Returns true if the hook was registered.
|
|
561
1363
|
*/
|
|
562
|
-
async function registerV2PermissionHook(
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
1364
|
+
async function registerV2PermissionHook(
|
|
1365
|
+
context: PluginContext,
|
|
1366
|
+
subagentOrigins: V2SubagentOriginRegistry,
|
|
1367
|
+
): Promise<boolean> {
|
|
1368
|
+
const domains = hostDomains(context)
|
|
1369
|
+
const permCtx = domains.permission
|
|
568
1370
|
|
|
569
1371
|
if (!permCtx?.hook) {
|
|
570
1372
|
markUnsupported('permission-hook')
|
|
@@ -572,9 +1374,117 @@ async function registerV2PermissionHook(context: PluginContext): Promise<boolean
|
|
|
572
1374
|
}
|
|
573
1375
|
|
|
574
1376
|
try {
|
|
575
|
-
await permCtx.hook('evaluate', (
|
|
576
|
-
|
|
577
|
-
|
|
1377
|
+
await permCtx.hook('evaluate', async (rawEvent: unknown) => {
|
|
1378
|
+
const event = rawEvent as V2PermissionEvent | null
|
|
1379
|
+
const effectBefore = event?.effect
|
|
1380
|
+
let origin: ReturnType<V2SubagentOriginRegistry['consume']>
|
|
1381
|
+
let decision: string | null = null
|
|
1382
|
+
let callerSessionParentID: unknown
|
|
1383
|
+
const writeProof = (): void => {
|
|
1384
|
+
const proofPath = process.env.PANTHEON_HOOK_CANARY_PERMISSION_PROOF
|
|
1385
|
+
if (!proofPath || !event || !Array.isArray(event.resources)) return
|
|
1386
|
+
try {
|
|
1387
|
+
appendFileSync(
|
|
1388
|
+
proofPath,
|
|
1389
|
+
`permission.evaluate ${JSON.stringify({
|
|
1390
|
+
action: event.action,
|
|
1391
|
+
resources: event.resources,
|
|
1392
|
+
agent: event.agent,
|
|
1393
|
+
sessionID: event.sessionID,
|
|
1394
|
+
callerSessionParentID: callerSessionParentID ?? null,
|
|
1395
|
+
source: event.source,
|
|
1396
|
+
matchedOrigin: origin ?? null,
|
|
1397
|
+
decision,
|
|
1398
|
+
effect: event.effect,
|
|
1399
|
+
message: event.message,
|
|
1400
|
+
eventFields: ['action', 'resources', 'agent', 'sessionID', 'source', 'effect'].filter(
|
|
1401
|
+
(key) => Object.hasOwn(event, key),
|
|
1402
|
+
),
|
|
1403
|
+
effectBefore: effectBefore ?? null,
|
|
1404
|
+
effectAfter: event.effect ?? null,
|
|
1405
|
+
})}\n`,
|
|
1406
|
+
)
|
|
1407
|
+
} catch {
|
|
1408
|
+
// Instrumentation is best-effort and must not affect permission flow.
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
const deny = (reason: string): void => {
|
|
1412
|
+
if (event === null || typeof event !== 'object') {
|
|
1413
|
+
throw new Error(
|
|
1414
|
+
`permission event is unavailable; denying V2 permission evaluation (${reason})`,
|
|
1415
|
+
)
|
|
1416
|
+
}
|
|
1417
|
+
event.effect = 'deny'
|
|
1418
|
+
event.message = `Pantheon delegation denied: ${reason}`
|
|
1419
|
+
if (event.effect !== 'deny') {
|
|
1420
|
+
throw new Error(
|
|
1421
|
+
`permission event is not mutable; denying V2 permission evaluation (${reason})`,
|
|
1422
|
+
)
|
|
1423
|
+
}
|
|
1424
|
+
writeProof()
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
if (event?.effect === 'deny') {
|
|
1428
|
+
writeProof()
|
|
1429
|
+
return // explicit host deny is final
|
|
1430
|
+
}
|
|
1431
|
+
if (event == null) {
|
|
1432
|
+
deny('permission event is missing')
|
|
1433
|
+
return
|
|
1434
|
+
}
|
|
1435
|
+
if (!Array.isArray(event.resources) || event.resources.length === 0) {
|
|
1436
|
+
deny('permission resources are missing or malformed')
|
|
1437
|
+
return
|
|
1438
|
+
}
|
|
1439
|
+
const targets = event.resources.map(v2ResourceTarget)
|
|
1440
|
+
if (targets.some((target) => target === undefined)) {
|
|
1441
|
+
deny('permission resource target is malformed')
|
|
1442
|
+
return
|
|
1443
|
+
}
|
|
1444
|
+
const hasPantheonTarget = targets.some((target) => PANTHEON_AGENTS.has(target ?? ''))
|
|
1445
|
+
let session: AuthoritativeSession | undefined
|
|
1446
|
+
if (hasPantheonTarget) {
|
|
1447
|
+
origin = subagentOrigins.consume(event)
|
|
1448
|
+
if (origin === undefined) {
|
|
1449
|
+
deny('matching host subagent tool origin is missing or stale')
|
|
1450
|
+
return
|
|
1451
|
+
}
|
|
1452
|
+
const sessionID = typeof event.sessionID === 'string' ? event.sessionID.trim() : ''
|
|
1453
|
+
if (!sessionID) {
|
|
1454
|
+
deny('caller session ID is missing')
|
|
1455
|
+
return
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
const sessionDomain = domains.session
|
|
1459
|
+
if (!sessionDomain || typeof sessionDomain.get !== 'function') {
|
|
1460
|
+
deny('session lookup API is unavailable')
|
|
1461
|
+
return
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
let retrieved: unknown
|
|
1465
|
+
try {
|
|
1466
|
+
retrieved = await sessionDomain.get({ sessionID })
|
|
1467
|
+
} catch {
|
|
1468
|
+
deny('caller session lookup failed')
|
|
1469
|
+
return
|
|
1470
|
+
}
|
|
1471
|
+
if (retrieved === null || retrieved === undefined) {
|
|
1472
|
+
deny('caller session was not found')
|
|
1473
|
+
return
|
|
1474
|
+
}
|
|
1475
|
+
if (typeof retrieved !== 'object' || Array.isArray(retrieved)) {
|
|
1476
|
+
deny('caller session lookup returned invalid data')
|
|
1477
|
+
return
|
|
1478
|
+
}
|
|
1479
|
+
session = retrieved as AuthoritativeSession
|
|
1480
|
+
callerSessionParentID = session.parentID
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
// Native-only resources remain under host policy. If a Pantheon target
|
|
1484
|
+
// appears anywhere, the shared policy evaluates the complete resource set.
|
|
1485
|
+
decision = evaluateV2Delegation(event, session, event.effect, origin)
|
|
1486
|
+
if (decision === 'deny') deny('caller or target is not allowed by delegation policy')
|
|
1487
|
+
else writeProof()
|
|
578
1488
|
})
|
|
579
1489
|
return true
|
|
580
1490
|
} catch {
|
|
@@ -586,7 +1496,15 @@ async function registerV2PermissionHook(context: PluginContext): Promise<boolean
|
|
|
586
1496
|
// ─── Tool Definition Factory ─────────────────────────────────────────────
|
|
587
1497
|
|
|
588
1498
|
/**
|
|
589
|
-
*
|
|
1499
|
+
* Minimal structural view of the V2 tool draft.
|
|
1500
|
+
*
|
|
1501
|
+
* `output` is REQUIRED. The host (opencode 2.0.22) enforces a biconditional on
|
|
1502
|
+
* a tool's declared `output` and the value its `execute` resolves to:
|
|
1503
|
+
* `def.output === undefined` together with `'output' in result` throws
|
|
1504
|
+
* "Tool result declared output without an output schema", and a declared
|
|
1505
|
+
* `output` whose result omits the key throws "Tool did not return its
|
|
1506
|
+
* declared output". A missing declaration therefore fails every call, so the
|
|
1507
|
+
* field is mandatory here rather than optional.
|
|
590
1508
|
*/
|
|
591
1509
|
interface V2ToolDraft {
|
|
592
1510
|
namespace(config: { name: string; description: string }): void
|
|
@@ -594,91 +1512,12 @@ interface V2ToolDraft {
|
|
|
594
1512
|
name: string
|
|
595
1513
|
description: string
|
|
596
1514
|
input: Record<string, unknown>
|
|
1515
|
+
output: Record<string, unknown>
|
|
597
1516
|
execute: (input: Record<string, unknown>, context: unknown) => Promise<V2ToolResult>
|
|
598
1517
|
}): void
|
|
599
1518
|
}
|
|
600
1519
|
|
|
601
|
-
|
|
602
|
-
* V2 tool result shape.
|
|
603
|
-
*
|
|
604
|
-
* The V1 SDK types `ToolResult` as `string | { output: string, ... }`, but
|
|
605
|
-
* the V2 beta host reads a field off the resolved value (minified `Ce`) and
|
|
606
|
-
* throws `Ce is not an Object` when a tool resolves to a bare string. Our
|
|
607
|
-
* handlers are fail-open, so that host-side TypeError escapes instead of
|
|
608
|
-
* being contained. Always resolve to the object shape — it satisfies both
|
|
609
|
-
* the SDK union and the beta host.
|
|
610
|
-
*/
|
|
611
|
-
export interface V2ToolResult {
|
|
612
|
-
output: string
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
/**
|
|
616
|
-
* A V2 tool definition ready for registration.
|
|
617
|
-
*/
|
|
618
|
-
interface V2ToolDef {
|
|
619
|
-
name: string
|
|
620
|
-
description: string
|
|
621
|
-
input: Record<string, unknown>
|
|
622
|
-
execute: (input: Record<string, unknown>, context: unknown) => Promise<V2ToolResult>
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
/**
|
|
626
|
-
* Create V2 tool definitions from the plugin context.
|
|
627
|
-
*
|
|
628
|
-
* When V1 infrastructure (BackgroundJobBoard, delegation client, etc.) is
|
|
629
|
-
* available through the V2 context, tools are wired directly. Otherwise,
|
|
630
|
-
* each tool returns an error message explaining the limitation.
|
|
631
|
-
*/
|
|
632
|
-
function createV2ToolDefinitionsFromContext(_context: PluginContext): V2ToolDef[] {
|
|
633
|
-
// Tool definitions are created lazily. When the full V1 infrastructure
|
|
634
|
-
// is bridged through the V2 context, these can be wired to real implementations.
|
|
635
|
-
// For now, they are structural definitions with placeholder execute functions
|
|
636
|
-
// that explain the V2 limitation.
|
|
637
|
-
const v2UnavailableMessage =
|
|
638
|
-
'This tool requires V1 infrastructure (BackgroundJobBoard, SDK client). ' +
|
|
639
|
-
'Use the V1 plugin (src/plugin.ts) for full tool support, or wire V1 ' +
|
|
640
|
-
'infrastructure through the V2 bridge (src/pantheon/v2-bridge.ts).'
|
|
641
|
-
|
|
642
|
-
const toolNames = [
|
|
643
|
-
'hashline_edit',
|
|
644
|
-
'pantheon_goal_create',
|
|
645
|
-
'pantheon_goal_get',
|
|
646
|
-
'pantheon_goal_update',
|
|
647
|
-
'pantheon_cost',
|
|
648
|
-
'pantheon_model',
|
|
649
|
-
]
|
|
650
|
-
|
|
651
|
-
const toolDescriptions: Record<string, string> = {
|
|
652
|
-
hashline_edit:
|
|
653
|
-
'Edit a file anchored by hashline refs (LINE#TAG) instead of raw line numbers. ' +
|
|
654
|
-
'Ops: replace, append, prepend, delete. ALL refs validated against ORIGINAL file first.',
|
|
655
|
-
pantheon_goal_create:
|
|
656
|
-
'Create the single active goal for this session. The goal loop then continues the ' +
|
|
657
|
-
'session automatically on idle until the goal is marked done.',
|
|
658
|
-
pantheon_goal_get:
|
|
659
|
-
'Return the active goal for this session (status, objective, continuations).',
|
|
660
|
-
pantheon_goal_update:
|
|
661
|
-
'Update the active goal: set status (pending/in_progress/done) and/or rewrite the objective. ' +
|
|
662
|
-
'Setting status to done stops all further continuations.',
|
|
663
|
-
pantheon_cost:
|
|
664
|
-
'Report delegation cost + token usage by agent over the last N days, read from opencode.db.',
|
|
665
|
-
pantheon_model:
|
|
666
|
-
'Show, set, or reset per-agent model overrides in active-preset.json (project or global).',
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
return toolNames.map((name) => ({
|
|
670
|
-
name,
|
|
671
|
-
description: toolDescriptions[name] ?? `Pantheon tool: ${name}`,
|
|
672
|
-
input: { type: 'object' as const, properties: {} },
|
|
673
|
-
// FOLLOW-UP: wire the real tool wrappers (costCommand/goalTools) once V1
|
|
674
|
-
// infrastructure is resolvable in the V2 standalone context. Until then,
|
|
675
|
-
// placeholders resolve to the object shape so the beta host never sees a
|
|
676
|
-
// bare string.
|
|
677
|
-
execute: async (_input: Record<string, unknown>, _context: unknown): Promise<V2ToolResult> => {
|
|
678
|
-
return { output: `${name}: ${v2UnavailableMessage}` }
|
|
679
|
-
},
|
|
680
|
-
}))
|
|
681
|
-
}
|
|
1520
|
+
export type { V2ToolDef, V2ToolResult } from './pantheon/v2-tools.ts'
|
|
682
1521
|
|
|
683
1522
|
// ─── Plugin Definition ───────────────────────────────────────────────────
|
|
684
1523
|
|
|
@@ -686,6 +1525,83 @@ function createV2ToolDefinitionsFromContext(_context: PluginContext): V2ToolDef[
|
|
|
686
1525
|
interface V2SessionEvent {
|
|
687
1526
|
type: string
|
|
688
1527
|
properties?: Record<string, unknown>
|
|
1528
|
+
data?: unknown
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/** Opt-in trace used by the isolated F4A canary; never logs prompts or inputs. */
|
|
1532
|
+
function writeV2AdmissionProof(label: string, detail: Record<string, unknown>): void {
|
|
1533
|
+
const proofPath = process.env.PANTHEON_HOOK_CANARY_PERMISSION_PROOF
|
|
1534
|
+
if (!proofPath) return
|
|
1535
|
+
try {
|
|
1536
|
+
appendFileSync(proofPath, `${label} ${JSON.stringify(detail)}\n`)
|
|
1537
|
+
} catch {
|
|
1538
|
+
// Proof is best-effort and must not affect admission behavior.
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
async function prepareV2BoundedRead(
|
|
1543
|
+
context: PluginContext,
|
|
1544
|
+
): Promise<ReturnType<typeof createV2BoundedReadTool> | undefined> {
|
|
1545
|
+
const host = hostDomains(context)
|
|
1546
|
+
const directory = host.location?.directory
|
|
1547
|
+
const projectID = host.location?.project?.id
|
|
1548
|
+
const session = host.session
|
|
1549
|
+
const permission = host.permission
|
|
1550
|
+
if (
|
|
1551
|
+
typeof directory !== 'string' ||
|
|
1552
|
+
directory.trim() === '' ||
|
|
1553
|
+
typeof projectID !== 'string' ||
|
|
1554
|
+
projectID.trim() === '' ||
|
|
1555
|
+
typeof session?.get !== 'function' ||
|
|
1556
|
+
typeof permission?.assert !== 'function' ||
|
|
1557
|
+
typeof host.event?.subscribe !== 'function' ||
|
|
1558
|
+
typeof host.tool?.transform !== 'function' ||
|
|
1559
|
+
typeof host.tool?.hook !== 'function'
|
|
1560
|
+
) {
|
|
1561
|
+
markUnsupported('bounded-read')
|
|
1562
|
+
return undefined
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
const rootDirectory = await realpathAsync(directory).catch(() => undefined)
|
|
1566
|
+
if (rootDirectory === undefined) {
|
|
1567
|
+
markUnsupported('bounded-read')
|
|
1568
|
+
return undefined
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
return createV2BoundedReadTool({
|
|
1572
|
+
rootDirectory,
|
|
1573
|
+
projectID,
|
|
1574
|
+
getSession: (sessionID) => {
|
|
1575
|
+
if (typeof session.get !== 'function') throw new Error('session lookup unavailable')
|
|
1576
|
+
return session.get.call(session, { sessionID })
|
|
1577
|
+
},
|
|
1578
|
+
assertPermission: (request) => {
|
|
1579
|
+
if (typeof permission.assert !== 'function') throw new Error('permission API unavailable')
|
|
1580
|
+
return permission.assert.call(permission, request)
|
|
1581
|
+
},
|
|
1582
|
+
scanContent: async ({ content, agent, sessionID, callID }) => {
|
|
1583
|
+
try {
|
|
1584
|
+
const scan = await secretScanRunner({
|
|
1585
|
+
tool_name: BOUNDED_READ_TOOL,
|
|
1586
|
+
tool_input: { content },
|
|
1587
|
+
agent_id: agent,
|
|
1588
|
+
session_id: sessionID,
|
|
1589
|
+
})
|
|
1590
|
+
const decision = evaluateSecretScanResult(scan)
|
|
1591
|
+
if (decision.action === 'allow') return 'allow'
|
|
1592
|
+
const reason = decision.action === 'fail-closed' ? decision.reason : 'content-match'
|
|
1593
|
+
appendV2HookLog(
|
|
1594
|
+
`[SECRET SCAN] bounded read withheld session=${sessionID} callID=${callID} reason=${reason}`,
|
|
1595
|
+
)
|
|
1596
|
+
return decision.action === 'fail-closed' ? 'unavailable' : 'deny'
|
|
1597
|
+
} catch {
|
|
1598
|
+
appendV2HookLog(
|
|
1599
|
+
`[SECRET SCAN] bounded read withheld session=${sessionID} callID=${callID} reason=scanner-failure`,
|
|
1600
|
+
)
|
|
1601
|
+
return 'unavailable'
|
|
1602
|
+
}
|
|
1603
|
+
},
|
|
1604
|
+
})
|
|
689
1605
|
}
|
|
690
1606
|
|
|
691
1607
|
/** Cleanup function type. */
|
|
@@ -699,17 +1615,21 @@ export const plugin = define({
|
|
|
699
1615
|
|
|
700
1616
|
async setup(context: PluginContext): Promise<void> {
|
|
701
1617
|
// ─── Phase 1: V2 Transforms (isolated per-domain, best-effort) ────
|
|
702
|
-
// Each
|
|
703
|
-
//
|
|
704
|
-
// reject setup()
|
|
1618
|
+
// Each transform registration is individually wrapped and settled via Promise.allSettled:
|
|
1619
|
+
// a rejection marks only that transform unsupported
|
|
1620
|
+
// and does not reject setup() or prevent later hook registrations.
|
|
705
1621
|
// See issue #92.
|
|
1622
|
+
//
|
|
1623
|
+
// Pantheon's Phase 1/config transform registrations cover agent, command,
|
|
1624
|
+
// and reference; ctx.tool.transform is attempted separately in Phase 2.
|
|
1625
|
+
// ctx.integration and ctx.skill are present on a live 2.0.22 host and neither
|
|
1626
|
+
// is registered here — that is a Pantheon scope choice, not a host gap.
|
|
1627
|
+
// ctx.catalog is the one domain the host does not provide.
|
|
1628
|
+
// Phase 1 registers against the SDK-typed `context.agent` / `.command` /
|
|
1629
|
+
// `.reference` directly; the host domains the SDK omits are reached only
|
|
1630
|
+
// through `hostDomains` (see the module header).
|
|
706
1631
|
const phase1Transforms: Array<{ feature: string; register: () => Promise<unknown> }> = [
|
|
707
1632
|
{ feature: 'agent-transform', register: () => context.agent.transform(transformAgents) },
|
|
708
|
-
{
|
|
709
|
-
feature: 'catalog-transform',
|
|
710
|
-
register: () =>
|
|
711
|
-
context.catalog.transform((draft) => transformCatalog(draft, context.options)),
|
|
712
|
-
},
|
|
713
1633
|
{
|
|
714
1634
|
feature: 'command-transform',
|
|
715
1635
|
register: () => context.command.transform(transformCommands),
|
|
@@ -718,7 +1638,6 @@ export const plugin = define({
|
|
|
718
1638
|
feature: 'reference-transform',
|
|
719
1639
|
register: () => context.reference.transform(transformReferences),
|
|
720
1640
|
},
|
|
721
|
-
{ feature: 'skill-transform', register: () => context.skill.transform(transformSkills) },
|
|
722
1641
|
]
|
|
723
1642
|
const phase1Results = await Promise.allSettled(
|
|
724
1643
|
phase1Transforms.map((t) => Promise.resolve().then(() => t.register())),
|
|
@@ -731,49 +1650,79 @@ export const plugin = define({
|
|
|
731
1650
|
}
|
|
732
1651
|
})
|
|
733
1652
|
|
|
734
|
-
|
|
735
|
-
const
|
|
736
|
-
if (
|
|
737
|
-
markUnsupported('tool-transform')
|
|
738
|
-
}
|
|
1653
|
+
const subagentOrigins = new V2SubagentOriginRegistry()
|
|
1654
|
+
const boundedRead = await prepareV2BoundedRead(context).catch(() => undefined)
|
|
1655
|
+
if (boundedRead === undefined) markUnsupported('bounded-read')
|
|
739
1656
|
|
|
740
|
-
// ─── Phase
|
|
741
|
-
const eventCleanup = subscribeV2Events(context)
|
|
1657
|
+
// ─── Phase 2: V2 Event Subscription (best-effort) ────────────────
|
|
1658
|
+
const eventCleanup = subscribeV2Events(context, boundedRead?.clearSession)
|
|
742
1659
|
if (eventCleanup) {
|
|
743
1660
|
activeCleanups.push(eventCleanup)
|
|
744
1661
|
} else {
|
|
745
1662
|
markUnsupported('event-stream')
|
|
746
1663
|
}
|
|
747
1664
|
|
|
748
|
-
// ─── Phase
|
|
1665
|
+
// ─── Phase 3: V2 Session Hooks (best-effort) ─────────────────────
|
|
749
1666
|
const sessionHooksRegistered = await registerV2SessionHooks(context).catch(() => false)
|
|
750
1667
|
if (!sessionHooksRegistered) {
|
|
751
1668
|
markUnsupported('session-hooks')
|
|
752
1669
|
}
|
|
753
1670
|
|
|
754
|
-
// ─── Phase
|
|
755
|
-
const
|
|
756
|
-
|
|
1671
|
+
// ─── Phase 4: V2 Tool Hooks (best-effort) ────────────────────────
|
|
1672
|
+
const toolHookResult = await registerV2ToolHooks(
|
|
1673
|
+
context,
|
|
1674
|
+
subagentOrigins,
|
|
1675
|
+
boundedRead?.origins,
|
|
1676
|
+
).catch(() => ({ registered: false, beforeRegistered: false }))
|
|
1677
|
+
if (!toolHookResult.registered) {
|
|
757
1678
|
markUnsupported('tool-execute-hooks')
|
|
758
1679
|
}
|
|
759
1680
|
|
|
1681
|
+
// ─── Phase 5: V2 Tool Registration (best-effort) ─────────────────
|
|
1682
|
+
const boundedReadDefinition = toolHookResult.beforeRegistered
|
|
1683
|
+
? boundedRead?.definition
|
|
1684
|
+
: undefined
|
|
1685
|
+
if (boundedRead !== undefined && boundedReadDefinition === undefined) {
|
|
1686
|
+
markUnsupported('bounded-read')
|
|
1687
|
+
}
|
|
1688
|
+
const toolsRegistered = await registerV2Tools(context, boundedReadDefinition).catch(() => false)
|
|
1689
|
+
if (!toolsRegistered) markUnsupported('tool-transform')
|
|
1690
|
+
|
|
760
1691
|
// ─── Phase 6: V2 Permission Hook (best-effort) ───────────────────
|
|
761
|
-
await registerV2PermissionHook(context).catch(() => false)
|
|
1692
|
+
await registerV2PermissionHook(context, subagentOrigins).catch(() => false)
|
|
762
1693
|
|
|
763
1694
|
// ─── Phase 7: V2 Compaction Hook (best-effort) ───────────────────
|
|
764
|
-
const compactionCtx = (context
|
|
765
|
-
| {
|
|
766
|
-
hook?: (
|
|
767
|
-
name: string,
|
|
768
|
-
handler: (event: unknown) => void | Promise<void>,
|
|
769
|
-
) => Promise<unknown>
|
|
770
|
-
}
|
|
771
|
-
| undefined
|
|
1695
|
+
const compactionCtx = hostDomains(context).session
|
|
772
1696
|
if (compactionCtx?.hook) {
|
|
773
1697
|
try {
|
|
774
|
-
|
|
1698
|
+
// 2.0.16 `SessionHooks` key is `compaction` (the migration doc maps
|
|
1699
|
+
// `experimental.session.compacting` → `ctx.session.hook("compaction")`).
|
|
1700
|
+
// `compacting` is not a valid key: the registry accepts any string and
|
|
1701
|
+
// silently drops unknown names, so the old name was a permanent no-op.
|
|
1702
|
+
// Proven by the canary's negative control.
|
|
1703
|
+
await compactionCtx.hook('compaction', (_event: unknown) => {
|
|
775
1704
|
// Compaction context build — injects active goals, pending todos,
|
|
776
|
-
// and in-flight delegations.
|
|
1705
|
+
// and in-flight delegations. No V2-side implementation: that build
|
|
1706
|
+
// lives in the V1 path, which is not loaded on a V2-only install.
|
|
1707
|
+
// This registration is a real hook (the canary below proves it
|
|
1708
|
+
// fires) with no V2 behaviour behind it.
|
|
1709
|
+
//
|
|
1710
|
+
// Opt-in lifetime proof for the host-backed hook canary
|
|
1711
|
+
// (tests/pantheon/plugin-v2-hook-canary.test.mjs, MODE=real). It is
|
|
1712
|
+
// inert unless PANTHEON_HOOK_CANARY_LIFETIME_PROOF is set, so
|
|
1713
|
+
// production behaviour is unchanged. This exists because a rename to
|
|
1714
|
+
// `compacting` was a silent no-op for an unknown length of time: the
|
|
1715
|
+
// canary can now observe that THIS registration fired.
|
|
1716
|
+
if (process.env.PANTHEON_HOOK_CANARY_LIFETIME_PROOF) {
|
|
1717
|
+
try {
|
|
1718
|
+
appendFileSync(
|
|
1719
|
+
process.env.PANTHEON_HOOK_CANARY_LIFETIME_PROOF,
|
|
1720
|
+
'plugin-v2:session.hook:compaction\n',
|
|
1721
|
+
)
|
|
1722
|
+
} catch {
|
|
1723
|
+
// Proof is best-effort: never let instrumentation break the host.
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
777
1726
|
})
|
|
778
1727
|
} catch {
|
|
779
1728
|
markUnsupported('compaction-hook')
|
|
@@ -802,7 +1751,14 @@ export function v2Dispose(): void {
|
|
|
802
1751
|
}
|
|
803
1752
|
|
|
804
1753
|
/**
|
|
805
|
-
*
|
|
1754
|
+
* The current list of unsupported V2 features.
|
|
1755
|
+
*
|
|
1756
|
+
* Read by the installer and by `doctor` (both via the shared seed in
|
|
1757
|
+
* `src/pantheon/v2-unsupported.mjs`) so the markers a user is shown are the
|
|
1758
|
+
* same ones this returns, rather than a hand-copied duplicate.
|
|
1759
|
+
*
|
|
1760
|
+
* This returns the LIVE array: the shared seed plus whatever `markUnsupported`
|
|
1761
|
+
* appended while this process talked to a real host.
|
|
806
1762
|
*/
|
|
807
1763
|
export function getUnsupportedFeatures(): readonly string[] {
|
|
808
1764
|
return V2_UNSUPPORTED_FEATURES
|