Skip to content

fix(plugin): opencode hook bridge spawns process.execPath — hooks silently no-op and leak ~14MB to /tmp per spawn #4849

Closed

Description

@jiangzhuan

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 /tmp

Because stdout is empty and exit code is 0 (not 2), handleHookResult() takes the silent-allow path:

if (!stdout && exitCode !== 2) return; // silent allow

So 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

  • /tmp leak from one session: 5,925 files, 75.8 GB (find /tmp -maxdepth 1 -name '.*-00000000.so'), each 13–14 MB, all created by short-lived opencode … <hook>.js children of the session process.
  • Spawn sites per tool call:
  • 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

  1. Resolve a real JS runtime in runHook() — prefer GSD_HOOK_RUNTIME env → node → bun → process.execPath only if it is actually a JS runtime; fall back with a loud warning otherwise.
  2. 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.
  3. 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".
  4. Add a regression test where process.execPath is not node (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

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

  1. trek-e commented on Sep 18, 2026

    @trek-e
    Collaborator

    This was generated by AI during triage.

    Diagnosis

    Reproduced. Confirmed by static code reading against the current next branch (no live
    opencode/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) spawns process.execPath — the
    host 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 as node <HOOKS_DIR>/<hook>.js, but under
    opencode, plugins run inside opencode's Bun single-file-executable host, so process.execPath is
    the opencode binary, not node. Spawning it with a .js path as the sole argument hits opencode's
    own 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-0
    result as "hook ran, nothing to report," so the guard is silently disabled rather than failing loud.
    .kilo/plugins/gsd-core.js:230 carries the identical line (it's maintained as a verbatim copy of
    the opencode file per src/host-integration.cts:792-793).

    Introduced by: commit ff6b5cb024 ("feat(#1914): OpenCode native plugin integration (Option 1
    file-copy) (#1923)", 2026-07-02) — git log -S on this file shows no earlier revision of the
    spawnSync(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 scripts
    across 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 is
    essentially every guard and advisory hook GSD ships to opencode, silently non-functional.

    Coupled: .kilo/plugins/gsd-core.js:230 has the identical line (verbatim copy); Kilo's own host
    runtime 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 Read
    of a sensitive path) is still enforced rather than silently allowed. Separately assert the hook
    runtime is resolved deliberately (e.g. GSD_HOOK_RUNTIME override → node on 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:

    • Under opencode, a hook script's actual logic executes on every guarded event (verifiable via a
      guard that should block an action, e.g. reading a secret file, actually blocking it).
    • If a suitable script-runtime cannot be located at all, the failure is surfaced loudly (visible
      warning/error), never silently treated as "allow."
    • No behavior change for the Claude Code hook path or for any already-correct runtime (this is
      opencode/Kilo-specific).
    • The per-invocation temp-file leak no longer accumulates once hooks route through the correct
      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-time process.execPath checks, which are a separate, already-correct
    mechanism.

  2. added
    bugSomething isn't working
    and removed
    needs-triageNew issue awaiting maintainer review
    on Sep 18, 2026
  3. added a commit that references this issue on Oct 4, 2026
  4. reopened this on Oct 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingconfirmed-bugVerified reproducible bug

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions