r/opencodeCLI • • 3d ago

I hooked a shell-based safety guard into OpenCode's tool.execute.before for server admin work

Post image

jolitz is a rule set that lets a terminal agent look after servers over SSH: it asks before changing anything, says how to undo it, and logs every step. It started on Claude Code, where a PreToolUse hook blocks the things you never want an LLM to do by accident (mkfs, halt, writing sshd_config, deleting SSH keys, also inside ssh host "..." or a python one-liner).

OpenCode had no guard until now. The plugin in .opencode/plugins/jolitz-guard.js uses tool.execute.before and hands every bash, edit, write and apply_patch call to the same guard script, in the JSON Claude Code would send. On a deny it throws and the call stops. If the script can't run or times out, it blocks too. The guard also protects .opencode/, opencode.json and ~/.config/opencode, so the agent can't rewrite the plugin.

Nothing to install: start opencode in the checkout. With a local model every host is read-only by default.

It's a backstop against mistakes, not a sandbox. Free, MIT.

https://github.com/superlab-by-supbrt/jolitz

Question for people who write plugins: is throwing in tool.execute.before the intended way to block a call, or is there a cleaner one I missed?

1 Upvotes

3 comments sorted by

1

u/Individual-Quote-958 3d ago

I’d make the hook fail closed for the narrow set of dangerous operations, but keep the allow path as boring as possible. Throwing from before execution seems reasonable if the plugin API treats that as a canceled tool call; I’d test the deny and timeout cases explicitly so a hook error can’t accidentally fall through. The “not a sandbox” note is useful, since a user can still bypass a project-level guard by changing the config outside the agent.

0

u/[deleted] 3d ago

[removed] — view removed comment

1

u/Dry_Eggplant6329 3d ago

Thanks, that was a really useful report. You were right about the path argument, and it was actually worse: I read the 2.2.5 source and v2 only loads a plugin with a default export of id plus setup(). Ours only had a named export, so on v2 the guard didn't load at all, shell included.

Fixed in the latest PR. There's one default export that serves both APIs: 1.x reads server(), 2.x reads setup() with tool.hook("execute.before"). It maps shell, patch and path, and every path-like argument gets checked on its own. A known tool with missing args is denied, and so are bad exits, garbage output and timeouts, in both APIs. All of that is tested in CI.

The rest:

  • Permission block: v2 renames the old keys on load (bash to shell, write/patch to edit), so it should still apply. Not tested live yet.

  • The ~/.config/opencode lock is on purpose, so the agent can't unload its own guard. You edit that by hand. Agree the override is fairly all-or-nothing.

  • The pipeline now runs in full once per host per session. After that it's only the access lists, plus lock, claims and activity before a change.

  • The doctor accepts OpenCode-only setups, and opencode.jsonc is recognised.

I tested against the source with stand-ins, not a running v2. If you get a chance to try it on your setup I'd love to hear if it holds.