Repository navigation
fix(plugin): opencode hook bridge spawns process.execPath — hooks silently no-op and leak ~14MB to /tmp per spawn #4849
Description
Summary
On the opencode runtime, GSD's plugin hook bridge (plugins/gsd-core.js) spawns every hook script with process.execPath:
result = spawnSync(process.execPath, [hookPath], { input: JSON.stringify(payload), ... });The file header states the intent is node <HOOKS_DIR>/<hook>.js. On opencode, process.execPath is the opencode Bun single-file binary. Spawning it with a .js path does not run the script — opencode treats the argument as a project directory and exits 0 after booting:
$ echo '{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"/etc/hostname"},"cwd":"<project>"}' \
| /home/goodb/.opencode/bin/opencode <config-dir>/hooks/gsd-secret-read-guard.js
rc=0
stdout: 0 bytes
stderr: Error: Failed to change directory to <config-dir>/hooks/gsd-secret-read-guard.js
+1 file in /tmpBecause stdout is empty and exit code is 0 (not 2), handleHookResult() takes the silent-allow path:
if (!stdout && exitCode !== 2) return; // silent allowSo every hook dispatched through runHook() is silently ineffective on opencode (secret-read-guard, read-injection-scanner, workflow-guard, prompt-guard, context-monitor, …) — the same "guard silently NOT enforced" failure mode the missing-hook warning already calls out (#2305), reached through a different path.
At the same time, each spawn boots the Bun single-file binary, which unpacks its embedded native library (~13–14 MB) to /tmp/.<hash>-00000000.so and never removes it. Hooks fire per tool event (1–3 spawns per tool call), so a normal working session leaks continuously.
Environment
- gsd-core 1.14.0, local install (
npx @opengsd/gsd-core@1.14.0 --opencode --local) - opencode 1.18.31 (Bun-compiled single-file binary at
/home/goodb/.opencode/bin/opencode) - Node v24.15.0, Linux (WSL2)
- Triggering session: 2h13m, cwd a project using
/gsd-*workflows, normal tool usage
Measurements
/tmpleak from one session: 5,925 files, 75.8 GB (find /tmp -maxdepth 1 -name '.*-00000000.so'), each 13–14 MB, all created by short-livedopencode … <hook>.jschildren of the session process.- Spawn sites per tool call:
tool.execute.before→gsd-secret-read-guard.jsforRead/Grep/Bashtool.execute.after→gsd-read-injection-scanner.jsforRead/WebFetch/WebSearch, plusgsd-context-monitor.jsfor all tools (this one is at least skippable viahooks.context_warnings: false, gsd-core.js plugin spawns subprocesses on every tool call, breaking OpenCode context caching #2697)
- The origin session sat at ~70% CPU with a steady stream of dying child processes.
- Note: the opencode binary leaks the same file even for
opencode --version(+1.so), i.e. the temp-file behavior is on the host side; GSD's spawn of that binary per hook event is what turns it into a leak.
Suggested fix
- Resolve a real JS runtime in
runHook()— preferGSD_HOOK_RUNTIMEenv →node→bun→process.execPathonly if it is actually a JS runtime; fall back with a loud warning otherwise. - Or run the hook logic in-process. The adapter already does in-process config checks (
contextWarningsDisabled, gsd-core.js plugin spawns subprocesses on every tool call, breaking OpenCode context caching #2697), so the precedent exists; the JSON-in/JSON-out hook contract could be adapted to a direct call for the bundled hooks. - Fail loud instead of silent-allow when the child produces no parseable stdout and looks like a runtime boot failure (e.g. detect the
Failed to change directory/ ENOENT shapes) — a guard that did not run must not read as "allow". - Add a regression test where
process.execPathis notnode(Bun SEA host), asserting the hook actually executes.
Local workaround (applied)
Patched the installed plugins/gsd-core.js to spawn node (with GSD_HOOK_RUNTIME override and ENOENT fallback to process.execPath). That both restores hook execution and stops the /tmp leak. This file is installer-managed, so the patch is lost on the next GSD update — hence this issue.
Related
- Kilo installs the guard plugin but stages no hook scripts — runHook silently no-ops all four PreToolUse guards #2305 — missing hook script warning ("guard silently NOT enforced")
- gsd-core.js plugin spawns subprocesses on every tool call, breaking OpenCode context caching #2697 — in-process skip for context-monitor when warnings disabled
- enhance(install): replace installer-written Read() deny rules with a managed secret-read guard hook — rules now trigger per-command approval prompts on Claude Code ≥ 2.1.259 #4221 —
gsd-secret-read-guard.js(one of the hooks affected here) - Feature: OpenCode native plugin integration #1914 — plugin two-distribution-shapes adapter
If the temp-file behavior itself should be fixed on the host side, that is a separate report against opencode/Bun (Bun single-file executables unpacks embedded native libs into /tmp and does not clean up on exit).
Activity
trek-e commentedon Sep 18, 2026
Diagnosis
Reproduced. Confirmed by static code reading against the current
nextbranch (no liveopencode/Bun runtime available in this pass, but the code path and the reporter's console
transcript line up exactly).
Root cause:
.opencode/plugins/gsd-core.js:230(runHook) spawnsprocess.execPath— thehost process's own executable — as if it were always a JS interpreter. The file's own header
(
.opencode/plugins/gsd-core.js:9) documents the intent asnode <HOOKS_DIR>/<hook>.js, but underopencode, plugins run inside opencode's Bun single-file-executable host, so
process.execPathisthe opencode binary, not
node. Spawning it with a.jspath as the sole argument hits opencode'sown CLI argument parsing (treats it as a project directory), boots and exits 0 with empty stdout —
never running the hook's logic.
.opencode/plugins/gsd-core.js:289(
if (!stdout && exitCode !== 2) return; // silent allow) then treats that empty-stdout/exit-0result as "hook ran, nothing to report," so the guard is silently disabled rather than failing loud.
.kilo/plugins/gsd-core.js:230carries the identical line (it's maintained as a verbatim copy ofthe opencode file per
src/host-integration.cts:792-793).Introduced by: commit
ff6b5cb024("feat(#1914): OpenCode native plugin integration (Option 1file-copy) (#1923)", 2026-07-02) —
git log -Son this file shows no earlier revision of thespawnSync(process.execPath, …)line. Three later commits touched this file (#2305, #2697, #4221)without changing the runtime-selection logic; all three are closed and are a different concern.
Blast radius: every
runHook()call site in the file is affected — 11 distinct hook scriptsacross 12 call sites:
gsd-prompt-guard.js,gsd-read-guard.js,gsd-worktree-path-guard.js,gsd-write-guard.js,gsd-workflow-guard.js,gsd-secret-read-guard.js,gsd-read-injection-scanner.js,gsd-context-monitor.js(2 sites),gsd-ensure-canonical-path.js,gsd-check-update.js,gsd-config-reload.js(.opencode/plugins/gsd-core.js:555-712). That isessentially every guard and advisory hook GSD ships to opencode, silently non-functional.
Coupled:
.kilo/plugins/gsd-core.js:230has the identical line (verbatim copy); Kilo's own hostruntime context wasn't verified in this pass, but any fix landed in the opencode file should be
re-checked before/when it's copied to the Kilo file. No open issue duplicates this report.
Regression test: stub the plugin's spawn dependency to reproduce "host process is not a JS
runtime" deterministically (no real Bun/opencode binary needed) — given a fake host that, invoked
with a script path, behaves like the opencode SEA host (exit 0, empty stdout, doesn't run the
script), assert a guard wired through the plugin's dispatch (e.g. the secret-read guard on a
Readof a sensitive path) is still enforced rather than silently allowed. Separately assert the hook
runtime is resolved deliberately (e.g.
GSD_HOOK_RUNTIMEoverride →nodeon PATH → loud failure)and never falls back to a non-Node host
execPath.Agent Brief
Category: Bug (security-relevant — guards silently fail open) + resource leak.
Summary: On the opencode runtime, GSD's hook bridge plugin spawns the wrong executable to run
its bundled hook scripts. Every hook silently no-ops instead of running, and each failed attempt
leaks several megabytes to the temp directory.
Current behavior: When any guarded tool event fires under opencode (file reads, writes, bash
commands, web fetches, etc.), the plugin tries to run the corresponding hook script but actually
launches the opencode runtime itself pointed at the script's path. That fails silently (no output,
success exit code), which the plugin's result handler treats as "hook allows the action with no
comment." The hook's actual logic — including security guards like the secret-read guard and the
prompt-injection scanner — never runs. Each of these failed attempts also leaves behind a multi-
megabyte temporary file that is never cleaned up, accumulating over a session.
Desired behavior: Hook scripts actually execute their logic under opencode, the same as they do
under Claude Code — guards enforce, advisories surface, and no destructive/insecure fallback exists.
If no suitable script runtime can be found at all, the plugin should fail loudly (a visible warning)
rather than silently allowing the action, so a broken hook is never indistinguishable from a hook
that ran and had nothing to flag. The temp-file leak, which is a byproduct of repeatedly invoking
the wrong executable, resolves once the correct one is used.
Key interfaces: The hook bridge's subprocess-spawning function that all guard/advisory hooks
route through; the result handler that decides allow vs. block vs. warn based on a spawned hook's
output and exit code; the equivalent bridge file shipped for the Kilo runtime, which mirrors this
one.
Acceptance criteria:
guard that should block an action, e.g. reading a secret file, actually blocking it).
warning/error), never silently treated as "allow."
opencode/Kilo-specific).
runtime.
Out of scope: Fixing the underlying host-runtime behavior of unpacking temp files on every
invocation (a separate, host-side concern) — only GSD's per-hook-event spawn frequency triggering it
repeatedly is in scope. Rewriting hook dispatch to run in-process rather than via subprocess (a
larger architectural change than this fix needs, though later work may consider it). Any change to
bin/install.js's own install-timeprocess.execPathchecks, which are a separate, already-correctmechanism.
fix(open-gsd#5215): prove hook registration activates and generate th…