pantheon-opencode 1.2.1 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +9 -0
- package/README.md +12 -13
- package/bin/pantheon-init.mjs +14 -0
- package/opencode.json +1 -1
- package/package.json +13 -4
- package/scripts/changelog-extract.mjs +112 -0
- package/scripts/doctor.mjs +303 -13
- package/scripts/hooks/audit-imports.sh +43 -0
- package/scripts/hooks/format-multi-language.sh +96 -0
- package/scripts/hooks/log-session-start.sh +24 -0
- package/scripts/hooks/on-subagent-delegation-start.sh +91 -0
- package/scripts/hooks/on-subagent-delegation-stop.sh +37 -0
- package/scripts/hooks/run-type-check.sh +21 -0
- package/scripts/hooks/scan-secrets.sh +55 -0
- package/scripts/hooks/validate-post-conditions.sh +21 -0
- package/scripts/hooks/validate-talos-scope.sh +43 -0
- package/scripts/hooks/validate-tool-safety.sh +46 -0
- package/scripts/install/opencode.mjs +58 -29
- package/scripts/install/plugin.mjs +4 -2
- package/scripts/install-mcp.mjs +104 -56
- package/scripts/mcp_persistence_server.py +1 -0
- package/scripts/prune.mjs +359 -0
- package/scripts/version-check.mjs +146 -0
- package/scripts/versioning.mjs +13 -14
- package/src/mcp/requirements-mcp.txt +8 -5
- package/src/mcp/requirements-vision.txt +3 -3
- package/src/plugins/hook-runner.ts +186 -0
- package/src/plugins/pantheon-hooks.ts +153 -0
- package/src/plugins/tui/dist/tui.d.ts +8 -0
- package/src/plugins/tui/dist/tui.d.ts.map +1 -0
- package/src/plugins/tui/dist/tui.js +1198 -0
- package/src/plugins/tui/dist/tui.js.map +1 -0
- package/src/plugins/tui/dist/tui.tsx +1204 -0
- package/src/plugins/tui/package.json +2 -2
- package/src/plugins/tui/src/index.tsx +15 -0
- package/src/mcp/requirements-mcp-core.txt +0 -5
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* version-check.mjs — Single source of truth enforcement
|
|
4
|
+
*
|
|
5
|
+
* package.json is the SOURCE OF TRUTH for the Pantheon version.
|
|
6
|
+
* Every other manifest (pyproject.toml, plugin.json, src/plugins/tui/package.json)
|
|
7
|
+
* MUST carry the same version. This script:
|
|
8
|
+
*
|
|
9
|
+
* - compares every manifest against package.json and prints a divergence
|
|
10
|
+
* table when they differ (exit code 1),
|
|
11
|
+
* - with --fix, rewrites each divergent manifest to the package.json version.
|
|
12
|
+
*
|
|
13
|
+
* Mirrors the MANIFEST_FILES list in scripts/versioning.mjs so `apply`
|
|
14
|
+
* and `check` can never drift apart.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* node scripts/version-check.mjs # verify (exit 0 = in sync)
|
|
18
|
+
* node scripts/version-check.mjs --fix # sync all manifests
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
22
|
+
import { join } from 'node:path'
|
|
23
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
24
|
+
|
|
25
|
+
/** Same set of manifests as versioning.mjs (minus package.json itself). */
|
|
26
|
+
export const MANIFESTS = [
|
|
27
|
+
{ name: 'pyproject.toml', file: 'pyproject.toml', kind: 'toml' },
|
|
28
|
+
{ name: 'plugin.json', file: 'plugin.json', kind: 'json' },
|
|
29
|
+
{ name: 'src/plugins/tui/package.json', file: join('src', 'plugins', 'tui', 'package.json'), kind: 'json' },
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
function readTomlVersion(filePath) {
|
|
33
|
+
const text = readFileSync(filePath, 'utf-8')
|
|
34
|
+
const match = text.match(/^version\s*=\s*"([^"]+)"/m)
|
|
35
|
+
if (!match) throw new Error(`no version field in ${filePath}`)
|
|
36
|
+
return match[1]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readJsonVersion(filePath) {
|
|
40
|
+
return JSON.parse(readFileSync(filePath, 'utf-8')).version
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readManifestVersion(filePath, kind) {
|
|
44
|
+
try {
|
|
45
|
+
return kind === 'toml' ? readTomlVersion(filePath) : readJsonVersion(filePath)
|
|
46
|
+
} catch {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function writeManifestVersion(filePath, kind, version) {
|
|
52
|
+
if (kind === 'toml') {
|
|
53
|
+
const text = readFileSync(filePath, 'utf-8')
|
|
54
|
+
const updated = text.replace(/^(version\s*=\s*")[^"]+(")/m, `$1${version}$2`)
|
|
55
|
+
writeFileSync(filePath, updated)
|
|
56
|
+
} else {
|
|
57
|
+
const content = JSON.parse(readFileSync(filePath, 'utf-8'))
|
|
58
|
+
content.version = version
|
|
59
|
+
writeFileSync(filePath, `${JSON.stringify(content, null, 2)}\n`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Compare every manifest version against package.json (source of truth).
|
|
65
|
+
*
|
|
66
|
+
* @param {string} root - repository root containing package.json + manifests
|
|
67
|
+
* @returns {{source: string, manifests: Array<{name: string, version: string|null, ok: boolean}>, ok: boolean}}
|
|
68
|
+
* - source: version read from package.json
|
|
69
|
+
* - manifests: per-manifest {name, version (null when unreadable), ok}
|
|
70
|
+
* - ok: true when every manifest matches the source version
|
|
71
|
+
*/
|
|
72
|
+
export function compareVersions(root) {
|
|
73
|
+
const source = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')).version
|
|
74
|
+
const manifests = MANIFESTS.map(({ name, file, kind }) => {
|
|
75
|
+
const version = readManifestVersion(join(root, file), kind)
|
|
76
|
+
return { name, version, ok: version === source }
|
|
77
|
+
})
|
|
78
|
+
return { source, manifests, ok: manifests.every((m) => m.ok) }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Rewrite every divergent manifest to the package.json version.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} root - repository root
|
|
85
|
+
* @returns {number} count of manifests rewritten
|
|
86
|
+
*/
|
|
87
|
+
export function syncToSource(root) {
|
|
88
|
+
const { source, manifests } = compareVersions(root)
|
|
89
|
+
let changed = 0
|
|
90
|
+
for (const manifest of manifests) {
|
|
91
|
+
if (manifest.ok) continue
|
|
92
|
+
const def = MANIFESTS.find((m) => m.name === manifest.name)
|
|
93
|
+
writeManifestVersion(join(root, def.file), def.kind, source)
|
|
94
|
+
changed += 1
|
|
95
|
+
}
|
|
96
|
+
return changed
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function printDivergenceTable(result) {
|
|
100
|
+
const rows = [
|
|
101
|
+
['manifest', 'version', 'status'],
|
|
102
|
+
...result.manifests.map((m) => [m.name, m.version ?? 'MISSING', m.ok ? 'ok' : 'DIVERGENT']),
|
|
103
|
+
]
|
|
104
|
+
const widths = rows[0].map((_, i) => Math.max(...rows.map((row) => row[i].length)))
|
|
105
|
+
console.log(`Source of truth: package.json → v${result.source}\n`)
|
|
106
|
+
for (const row of rows) {
|
|
107
|
+
console.log(row.map((cell, i) => cell.padEnd(widths[i])).join(' '))
|
|
108
|
+
}
|
|
109
|
+
console.log('')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const isDirectRun =
|
|
113
|
+
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
|
114
|
+
|
|
115
|
+
if (isDirectRun) {
|
|
116
|
+
// npm scripts run from the package root, so the working directory is the
|
|
117
|
+
// repository root to check. Explicit cwd keeps the CLI testable.
|
|
118
|
+
const root = process.cwd()
|
|
119
|
+
const fix = process.argv.includes('--fix')
|
|
120
|
+
let result
|
|
121
|
+
try {
|
|
122
|
+
result = compareVersions(root)
|
|
123
|
+
} catch (error) {
|
|
124
|
+
console.error(`version-check: cannot read package.json — ${error.message}`)
|
|
125
|
+
process.exit(1)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (result.ok) {
|
|
129
|
+
console.log(`✓ All manifests match package.json v${result.source}`)
|
|
130
|
+
process.exit(0)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
printDivergenceTable(result)
|
|
134
|
+
if (fix) {
|
|
135
|
+
const changed = syncToSource(root)
|
|
136
|
+
const after = compareVersions(root)
|
|
137
|
+
if (after.ok) {
|
|
138
|
+
console.log(`✓ Synced ${changed} manifest(s) to v${after.source}`)
|
|
139
|
+
process.exit(0)
|
|
140
|
+
}
|
|
141
|
+
console.error(`version-check: sync incomplete — ${after.manifests.filter((m) => !m.ok).length} manifest(s) still divergent`)
|
|
142
|
+
process.exit(1)
|
|
143
|
+
}
|
|
144
|
+
console.error('version-check: manifests out of sync (run with --fix to sync)')
|
|
145
|
+
process.exit(1)
|
|
146
|
+
}
|
package/scripts/versioning.mjs
CHANGED
|
@@ -29,7 +29,7 @@ const MANIFEST_FILES = [
|
|
|
29
29
|
'pyproject.toml',
|
|
30
30
|
'package.json',
|
|
31
31
|
'plugin.json',
|
|
32
|
-
'
|
|
32
|
+
'src/plugins/tui/package.json',
|
|
33
33
|
]
|
|
34
34
|
|
|
35
35
|
const CHANGELOG_PATH = join(ROOT, 'CHANGELOG.md')
|
|
@@ -47,8 +47,11 @@ function run(cmd) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
function getLatestTag() {
|
|
50
|
-
//
|
|
51
|
-
|
|
50
|
+
// Stable tags only: strict vX.Y.Z with NO pre-release suffix.
|
|
51
|
+
// The loose glob `v[0-9]*.[0-9]*.[0-9]*` ALSO matches v1.2.0-beta.9.*
|
|
52
|
+
// (the trailing `*` swallows the -beta suffix), which would poison the
|
|
53
|
+
// version gate — so filter strictly before sorting.
|
|
54
|
+
const tag = run("git tag -l 'v*' | grep -E '^v[0-9]+\\.[0-9]+\\.[0-9]+$' | sort -V | tail -1")
|
|
52
55
|
return tag || 'v0.0.0'
|
|
53
56
|
}
|
|
54
57
|
|
|
@@ -207,13 +210,15 @@ switch (command) {
|
|
|
207
210
|
const latestVer = latestTag.replace(/^v/, '')
|
|
208
211
|
|
|
209
212
|
// If package.json is already ahead of the latest tag, someone bumped
|
|
210
|
-
// without tagging — warn and use the current version as-is.
|
|
213
|
+
// without tagging — warn and use the current version as-is. Tags are
|
|
214
|
+
// workflow-owned, so there is nothing to create here.
|
|
211
215
|
if (current !== latestVer) {
|
|
212
216
|
console.log(`⚠ package.json (${current}) already ahead of latest tag (${latestTag}).`)
|
|
213
217
|
console.log(` Syncing all manifests to ${current} and promoting CHANGELOG.`)
|
|
214
218
|
updateManifests(current)
|
|
215
219
|
const date = new Date().toISOString().slice(0, 10)
|
|
216
220
|
promoteUnreleased(current, date)
|
|
221
|
+
console.log(`Tag v${current} will be created by the release workflow after merge to main.`)
|
|
217
222
|
break
|
|
218
223
|
}
|
|
219
224
|
|
|
@@ -226,16 +231,10 @@ switch (command) {
|
|
|
226
231
|
promoteUnreleased(newVersion, date)
|
|
227
232
|
console.log(`\nDone. Commit with: git add -A && git commit -m "chore(release): v${newVersion}"`)
|
|
228
233
|
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
console.log(`Tag ${tag} created locally. Push: git push origin ${tag}`)
|
|
234
|
-
console.log(`Auto-release will trigger on main after merge.`)
|
|
235
|
-
} catch (e) {
|
|
236
|
-
console.warn(`Tag creation failed: ${e.message}`)
|
|
237
|
-
console.log('Create manually: git tag -a v' + newVersion + ' -m "chore(release): v' + newVersion + '"')
|
|
238
|
-
}
|
|
234
|
+
// Tags are owned by the release workflow — never create them locally.
|
|
235
|
+
// A local tag on a pre-merge commit drifts from the merged main state
|
|
236
|
+
// (the untagged-v1.2.1 root cause) and would desync the version gate.
|
|
237
|
+
console.log(`Tag v${newVersion} will be created by the release workflow after merge to main.`)
|
|
239
238
|
break
|
|
240
239
|
}
|
|
241
240
|
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
pyyaml
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
# Pantheon MCP runtime dependencies — EXACT pins (issue #21)
|
|
2
|
+
# Pinned to the versions validated in the sandbox venv (2026-08-05).
|
|
3
|
+
# Update deliberately via: pip install -U <pkg> && pip freeze | grep -iE '^(mcp|fastmcp|pyyaml|sqlite-vec|fastembed)'
|
|
4
|
+
mcp==1.29.0
|
|
5
|
+
fastmcp==3.4.6
|
|
6
|
+
pyyaml==6.0.3
|
|
7
|
+
sqlite-vec==0.1.9
|
|
8
|
+
fastembed==0.8.0
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
mcp
|
|
2
|
-
fastmcp
|
|
3
|
-
httpx
|
|
1
|
+
mcp==1.29.0
|
|
2
|
+
fastmcp==3.4.6
|
|
3
|
+
httpx==0.28.1
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hook-runner.ts — version-proof runner for the Pantheon security-hook layer.
|
|
3
|
+
*
|
|
4
|
+
* The previous plugin used the Bun Shell `$` injected by the opencode plugin
|
|
5
|
+
* API (`$.quiet().nothrow().timeout(...)`). `.timeout()` does not exist on the
|
|
6
|
+
* BunShell interface, so every hook crashed with a TypeError and the layer was
|
|
7
|
+
* removed (24-25/07). This runner uses ONLY `node:child_process` — stable
|
|
8
|
+
* across every Node >= 18 runtime and every opencode plugin loader version.
|
|
9
|
+
*
|
|
10
|
+
* Claude Code stdin protocol: the hook scripts receive
|
|
11
|
+
* `{tool_name, tool_input, agent_id, session_id}` as JSON on stdin. Scripts
|
|
12
|
+
* that read env vars instead (log-session-start.sh, on-subagent-delegation-*)
|
|
13
|
+
* get their fields mapped into the child env (SESSION_ID / AGENT_NAME /
|
|
14
|
+
* TASK_DESC) so both protocols work end to end.
|
|
15
|
+
*
|
|
16
|
+
* IMPORTANT (verified empirically): the `input:` option on the async
|
|
17
|
+
* `execFile()` API is silently ignored by Node — it only exists on
|
|
18
|
+
* spawnSync/execFileSync. This runner therefore writes stdin explicitly to a
|
|
19
|
+
* spawned child, which delivers the payload reliably.
|
|
20
|
+
*
|
|
21
|
+
* Hooks NEVER throw: every failure (missing script, timeout, non-zero exit)
|
|
22
|
+
* is returned as a structured HookResult so the opencode session can never be
|
|
23
|
+
* taken down by the security layer.
|
|
24
|
+
*/
|
|
25
|
+
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
|
26
|
+
import path from 'node:path'
|
|
27
|
+
import type { Readable } from 'node:stream'
|
|
28
|
+
import { fileURLToPath } from 'node:url'
|
|
29
|
+
|
|
30
|
+
export const HOOK_TIMEOUT_MS = 15_000
|
|
31
|
+
export const HOOK_MAX_BUFFER = 10 * 1024 * 1024
|
|
32
|
+
|
|
33
|
+
/** Claude Code hook protocol payload. */
|
|
34
|
+
export type HookPayload = {
|
|
35
|
+
tool_name?: string
|
|
36
|
+
tool_input?: unknown
|
|
37
|
+
agent_id?: string
|
|
38
|
+
session_id?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type HookResult = {
|
|
42
|
+
code: number
|
|
43
|
+
stdout: string
|
|
44
|
+
stderr: string
|
|
45
|
+
signal: NodeJS.Signals | null
|
|
46
|
+
timedOut: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type RunHookOptions = {
|
|
50
|
+
env?: Record<string, string | undefined>
|
|
51
|
+
cwd?: string
|
|
52
|
+
timeout?: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the hooks directory relative to this module — robust to any npm
|
|
57
|
+
* global-install prefix because it anchors on import.meta.url, not __dirname
|
|
58
|
+
* or process.cwd().
|
|
59
|
+
*/
|
|
60
|
+
export function resolveHooksDir(): string {
|
|
61
|
+
return fileURLToPath(new URL('../../scripts/hooks/', import.meta.url))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Map the Claude Code protocol payload into the child environment. Scripts
|
|
66
|
+
* that read env vars (log-session-start.sh, delegation audit scripts) rely on
|
|
67
|
+
* these; scripts that read stdin receive the full JSON via `input` in runHook.
|
|
68
|
+
* PATH is always preserved so `python3`/`bash` resolution inside the scripts
|
|
69
|
+
* is never affected by a stripped-down environment.
|
|
70
|
+
*/
|
|
71
|
+
export function hookEnv(
|
|
72
|
+
payload: HookPayload,
|
|
73
|
+
extra?: Record<string, string | undefined>,
|
|
74
|
+
): NodeJS.ProcessEnv {
|
|
75
|
+
return {
|
|
76
|
+
...process.env,
|
|
77
|
+
PATH: process.env.PATH ?? '',
|
|
78
|
+
SESSION_ID: payload.session_id ?? '',
|
|
79
|
+
AGENT_NAME: payload.agent_id ?? '',
|
|
80
|
+
TASK_DESC: payload.tool_name ?? '',
|
|
81
|
+
...(extra ?? {}),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Run one hook script with the Claude Code protocol payload on stdin.
|
|
87
|
+
*
|
|
88
|
+
* @param script file name inside scripts/hooks/ (e.g. "validate-tool-safety.sh")
|
|
89
|
+
* @param payload protocol payload translated from the opencode hook event
|
|
90
|
+
* @param opts optional env overrides / cwd / timeout
|
|
91
|
+
* @returns HookResult — NEVER throws; resolve with a non-zero code instead
|
|
92
|
+
*/
|
|
93
|
+
export function runHook(
|
|
94
|
+
script: string,
|
|
95
|
+
payload: HookPayload,
|
|
96
|
+
opts: RunHookOptions = {},
|
|
97
|
+
): Promise<HookResult> {
|
|
98
|
+
const scriptPath = path.join(opts.cwd ?? resolveHooksDir(), script)
|
|
99
|
+
const timeout = opts.timeout ?? HOOK_TIMEOUT_MS
|
|
100
|
+
|
|
101
|
+
return new Promise<HookResult>((resolve) => {
|
|
102
|
+
const stdoutBuf: { value: string } = { value: '' }
|
|
103
|
+
const stderrBuf: { value: string } = { value: '' }
|
|
104
|
+
let settled = false
|
|
105
|
+
|
|
106
|
+
const settle = (result: HookResult) => {
|
|
107
|
+
if (!settled) {
|
|
108
|
+
settled = true
|
|
109
|
+
resolve(result)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const spawnOpts = {
|
|
114
|
+
timeout,
|
|
115
|
+
killSignal: 'SIGKILL' as const,
|
|
116
|
+
env: hookEnv(payload, opts.env),
|
|
117
|
+
// stdio:'pipe' is equivalent to ['pipe','pipe','pipe'] — it guarantees
|
|
118
|
+
// the child's stdin is OUR pipe, NEVER the opencode TUI's terminal
|
|
119
|
+
// (which would leave `cat`-style hooks blocked reading forever and hit
|
|
120
|
+
// the opencode core hook timeout).
|
|
121
|
+
stdio: 'pipe' as const,
|
|
122
|
+
windowsHide: true,
|
|
123
|
+
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// stdio:'pipe' picks the ChildProcessWithoutNullStreams overload — do not
|
|
127
|
+
// widen these option literals or child.stdin/stdout become nullable.
|
|
128
|
+
let child: ChildProcessWithoutNullStreams
|
|
129
|
+
try {
|
|
130
|
+
child = spawn(scriptPath, [], spawnOpts)
|
|
131
|
+
} catch (err) {
|
|
132
|
+
// Synchronous spawn failure (invalid args etc.) — never propagate.
|
|
133
|
+
settle({
|
|
134
|
+
code: 1,
|
|
135
|
+
stdout: '',
|
|
136
|
+
stderr: `spawn failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
137
|
+
signal: null,
|
|
138
|
+
timedOut: false,
|
|
139
|
+
})
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The child may exit before we write stdin — swallow EPIPE instead of
|
|
144
|
+
// crashing the plugin process with an unhandled stream error.
|
|
145
|
+
child.stdin.on('error', () => {})
|
|
146
|
+
|
|
147
|
+
const collect = (stream: Readable, buf: { value: string }) => {
|
|
148
|
+
stream.setEncoding('utf-8')
|
|
149
|
+
stream.on('data', (chunk: string) => {
|
|
150
|
+
if (buf.value.length < HOOK_MAX_BUFFER) {
|
|
151
|
+
buf.value += chunk.slice(0, HOOK_MAX_BUFFER - buf.value.length)
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
collect(child.stdout, stdoutBuf)
|
|
156
|
+
collect(child.stderr, stderrBuf)
|
|
157
|
+
|
|
158
|
+
child.on('error', (err: NodeJS.ErrnoException) => {
|
|
159
|
+
// ENOENT / EACCES — script missing or not executable. Report, never throw.
|
|
160
|
+
settle({
|
|
161
|
+
code: 1,
|
|
162
|
+
stdout: stdoutBuf.value,
|
|
163
|
+
stderr: stderrBuf.value || `hook script failed to spawn: ${err.message}`,
|
|
164
|
+
signal: null,
|
|
165
|
+
timedOut: false,
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
child.on('close', (code, signal) => {
|
|
170
|
+
settle({
|
|
171
|
+
code: code ?? 1,
|
|
172
|
+
stdout: stdoutBuf.value,
|
|
173
|
+
stderr: stderrBuf.value,
|
|
174
|
+
signal,
|
|
175
|
+
timedOut: code === null && signal === 'SIGKILL',
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// Deliver the Claude Code protocol JSON on stdin. stdin.end() runs
|
|
180
|
+
// UNCONDITIONALLY — even for an empty payload {} or a script that never
|
|
181
|
+
// reads stdin — so the child can never block waiting for input and hit
|
|
182
|
+
// the runtime hook timeout (reported as "Hook X timed out after 30000ms").
|
|
183
|
+
child.stdin.write(JSON.stringify(payload))
|
|
184
|
+
child.stdin.end()
|
|
185
|
+
})
|
|
186
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pantheon-hooks.ts — Pantheon runtime security-hook plugin for OpenCode.
|
|
3
|
+
*
|
|
4
|
+
* Re-created per Council Synthesis 2026-08-05 (P0): the previous hook plugin
|
|
5
|
+
* was removed on 24-25/07 because it used the Bun Shell `$` from the plugin
|
|
6
|
+
* API, whose interface has NO `.timeout()` method — every hook crashed with
|
|
7
|
+
* `TypeError: $.quiet().nothrow().timeout is not a function`. The 10 functional
|
|
8
|
+
* scripts in scripts/hooks/ (gitleaks covers only staged files, NOT tool
|
|
9
|
+
* calls) were left orphaned. This plugin wires them back into the opencode
|
|
10
|
+
* runtime via `node:child_process` (version-proof — see hook-runner.ts).
|
|
11
|
+
*
|
|
12
|
+
* Hook mapping (Claude Code protocol payload {tool_name, tool_input,
|
|
13
|
+
* agent_id, session_id} delivered on the script stdin):
|
|
14
|
+
* - tool.execute.before → validate-talos-scope.sh, scan-secrets.sh,
|
|
15
|
+
* validate-tool-safety.sh + on-subagent-delegation-start.sh (only when the
|
|
16
|
+
* tool is a delegation tool such as `task`, to keep the audit log honest)
|
|
17
|
+
* - tool.execute.after → format-multi-language.sh, log-session-start.sh +
|
|
18
|
+
* on-subagent-delegation-stop.sh (delegation tool only)
|
|
19
|
+
* - event session.created → log-session-start.sh, validate-post-conditions.sh
|
|
20
|
+
*
|
|
21
|
+
* Notes:
|
|
22
|
+
* - `agent_id` is NOT present in the opencode tool.execute hook payloads
|
|
23
|
+
* ({tool, sessionID, callID}), so it is passed as '' — validate-talos-scope
|
|
24
|
+
* then takes its safe skip path; the agent-agnostic scan-secrets and
|
|
25
|
+
* validate-tool-safety protections remain fully active.
|
|
26
|
+
* - tool.execute hooks cannot hard-deny the tool call through this API
|
|
27
|
+
* (the output only mutates args; real deny is the permission system), so
|
|
28
|
+
* violations surface as stderr + non-zero exit codes logged below.
|
|
29
|
+
* - Logging policy: non-zero exit → console.error (the security signal);
|
|
30
|
+
* zero exit → SILENT by default. Audit scripts (log-session-start,
|
|
31
|
+
* on-subagent-delegation-*) still write their log FILES (sessions.log,
|
|
32
|
+
* delegations.log) from inside the .sh scripts — only the console echo is
|
|
33
|
+
* suppressed so the chat/TUI stays clean. Set PANTHEON_HOOKS_LOG=1 (or
|
|
34
|
+
* "debug") to re-enable the zero-exit console echo for debugging.
|
|
35
|
+
* The env var is read once at plugin load time (opencode startup).
|
|
36
|
+
* - Hooks NEVER throw: every body is try/catch'd and only logs.
|
|
37
|
+
*
|
|
38
|
+
* IMPORTANT (OpenCode 1.18.11 legacy loader): this module must export EXACTLY
|
|
39
|
+
* ONE function-valued export — the default plugin (see src/plugin.ts L47-52).
|
|
40
|
+
* Helpers live in ./hook-runner.ts and are imported from there.
|
|
41
|
+
*/
|
|
42
|
+
import type { Plugin } from '@opencode-ai/plugin'
|
|
43
|
+
import { runHook, type HookPayload } from './hook-runner.ts'
|
|
44
|
+
|
|
45
|
+
/** Tools that represent a subagent delegation (opencode `task` tool etc.). */
|
|
46
|
+
const DELEGATION_TOOL_RE = /^(task|.*delegate.*|.*subagent.*)$/i
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* When set to a truthy value ("1" or "debug"), zero-exit audit-hook output is
|
|
50
|
+
* echoed to the console (opt-in debugging). Default (unset): fully silent on
|
|
51
|
+
* success — the .sh scripts still persist their log FILES on disk; only the
|
|
52
|
+
* console echo is suppressed. Read at plugin load time.
|
|
53
|
+
*/
|
|
54
|
+
const AUDIT_LOG_ENABLED = (process.env.PANTHEON_HOOKS_LOG ?? '').trim() !== ''
|
|
55
|
+
|
|
56
|
+
/** Scripts whose zero-exit stderr is relayed when PANTHEON_HOOKS_LOG is set. */
|
|
57
|
+
const AUDIT_HOOKS = new Set([
|
|
58
|
+
'log-session-start.sh',
|
|
59
|
+
'on-subagent-delegation-start.sh',
|
|
60
|
+
'on-subagent-delegation-stop.sh',
|
|
61
|
+
'validate-post-conditions.sh',
|
|
62
|
+
])
|
|
63
|
+
|
|
64
|
+
function trim(s: string, max = 2000): string {
|
|
65
|
+
const t = s.trim()
|
|
66
|
+
return t.length > max ? `${t.slice(0, max)}…` : t
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Translate the opencode hook event into the Claude Code protocol payload. */
|
|
70
|
+
function payloadFor(
|
|
71
|
+
input: { tool: string; sessionID: string },
|
|
72
|
+
toolInput: unknown,
|
|
73
|
+
): HookPayload {
|
|
74
|
+
return {
|
|
75
|
+
tool_name: input.tool,
|
|
76
|
+
tool_input: toolInput ?? {},
|
|
77
|
+
// tool.execute hooks carry no agent — '' lets talos-scope skip safely.
|
|
78
|
+
agent_id: (input as { agent?: { id?: string } }).agent?.id ?? '',
|
|
79
|
+
session_id: input.sessionID,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Run one hook with the logging policy; NEVER throw out of the hook. */
|
|
84
|
+
async function safeRun(script: string, payload: HookPayload): Promise<void> {
|
|
85
|
+
try {
|
|
86
|
+
const result = await runHook(script, payload)
|
|
87
|
+
const tag = `[pantheon-hooks:${script}]`
|
|
88
|
+
if (result.code !== 0) {
|
|
89
|
+
console.error(
|
|
90
|
+
`${tag} exit ${result.code}${result.timedOut ? ' (timed out)' : ''}: ${trim(result.stderr || result.stdout || 'no output')}`,
|
|
91
|
+
)
|
|
92
|
+
} else if (AUDIT_LOG_ENABLED && AUDIT_HOOKS.has(script) && result.stderr.trim()) {
|
|
93
|
+
// Opt-in only (PANTHEON_HOOKS_LOG=1): audit scripts echo their FILE
|
|
94
|
+
// writes here for debugging. Default is silence — the .sh scripts
|
|
95
|
+
// already persist sessions.log / delegations.log on disk.
|
|
96
|
+
console.log(`${tag} ${trim(result.stderr)}`)
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
// runHook never throws, but the hook body must survive anything.
|
|
100
|
+
console.error(
|
|
101
|
+
`[pantheon-hooks:${script}] unexpected failure: ${err instanceof Error ? err.message : String(err)}`,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const plugin: Plugin = async () => {
|
|
107
|
+
return {
|
|
108
|
+
'tool.execute.before': async (input, output) => {
|
|
109
|
+
try {
|
|
110
|
+
const payload = payloadFor(input, output.args ?? {})
|
|
111
|
+
const hooks = [
|
|
112
|
+
'validate-talos-scope.sh',
|
|
113
|
+
'scan-secrets.sh',
|
|
114
|
+
'validate-tool-safety.sh',
|
|
115
|
+
]
|
|
116
|
+
if (DELEGATION_TOOL_RE.test(input.tool)) {
|
|
117
|
+
hooks.push('on-subagent-delegation-start.sh')
|
|
118
|
+
}
|
|
119
|
+
await Promise.all(hooks.map((script) => safeRun(script, payload)))
|
|
120
|
+
} catch {
|
|
121
|
+
// Never let a hook take down the opencode session.
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
'tool.execute.after': async (input) => {
|
|
125
|
+
try {
|
|
126
|
+
const payload = payloadFor(input, input.args ?? {})
|
|
127
|
+
const hooks = ['format-multi-language.sh']
|
|
128
|
+
if (DELEGATION_TOOL_RE.test(input.tool)) {
|
|
129
|
+
hooks.push('on-subagent-delegation-stop.sh')
|
|
130
|
+
}
|
|
131
|
+
await Promise.all(hooks.map((script) => safeRun(script, payload)))
|
|
132
|
+
} catch {
|
|
133
|
+
// Never let a hook take down the opencode session.
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
event: async ({ event: ev }) => {
|
|
137
|
+
try {
|
|
138
|
+
if (ev.type === 'session.created') {
|
|
139
|
+
const payload: HookPayload = { session_id: ev.properties.info.id }
|
|
140
|
+
await Promise.all(
|
|
141
|
+
['log-session-start.sh', 'validate-post-conditions.sh'].map((script) =>
|
|
142
|
+
safeRun(script, payload),
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
// Never let a hook take down the opencode session.
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export default plugin
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tui.d.ts","names":[],"sources":["../src/index.tsx"],"mappings":";;cA8qCM,QAAQ;EAAoB"}
|