View source on GitHub
SessionStart hook bundled in a plugin.
The gpg-signer/ plugin imports a private key from a
custom secret and configures git to sign commits and tags
globally, so signing applies to any repo the agent clones during the
session.
The problem this solves
Teams often configure signing today in.openhands/setup.sh:
setup.sh only runs when the conversation is started with a repository
selected. Start a conversation with no repo (or have the agent clone a repo
later) and signing is silently missing.
A SessionStart hook runs at the beginning of every conversation,
regardless of whether a repo was picked — exactly when you want signing set up.
That is the whole idea behind this example.
Why the hook can read the same secret as setup.sh
On the agent-server, hook scripts and setup.sh execute against the same
process environment. A custom secret named gpg_key that setup.sh can read
as $gpg_key is visible to the SessionStart hook the same way — so the logic
you already run in setup.sh moves into the hook almost verbatim.
Two differences from the raw setup.sh version, both deliberate:
git config --globalinstead of a baregit config. There is no repository yet whenSessionStartfires, and a global config applies to every repo cloned afterwards.- Never fails the session.
SessionStarthooks cannot block, so on any problem (missing secret, bad key) the hook logs the reason to/tmp/openhands_gpg_setup.logand exits0.
The secret
Add these under Settings → Secrets in OpenHands (or pass them per conversation via the API):
If
gpg_key is absent the hook does nothing (and says so in the log), so it is
safe to load the plugin for everyone and let it activate only where a key is
configured.
Secret names are configurable through theGPG_KEY_SECRET_NAME,GIT_USER_NAME_SECRET_NAME, andGIT_USER_EMAIL_SECRET_NAMEenvironment variables if your org uses different names.
What’s in the box
The hook
The logic lives inhooks/hooks.json under the
SessionStart event:
SessionStart— runs once, when the conversation begins (no repo needed).- Reads the armored key from
$gpg_key, imports it withgpg --batch --import. - Extracts the primary key fingerprint (
gpg --show-keys --with-colons). - Sets
user.signingkey,commit.gpgsign true,tag.gpgsign true, andgpg.programglobally, plus the optional identity secrets. - Exits
0always; all human-readable output goes to/tmp/openhands_gpg_setup.log(on a successful hook, stdout is parsed as JSON, so it is kept clean).
Why the script is inlined (and mirrored inhooks/scripts/). When this runs as a plugin, hooks execute with the working directory set to the agent’s workspace (not the plugin directory), and there is no plugin-root path variable — socommandcannot point at a bundledhooks/scripts/*.sh. The runnable copy is therefore inlined inhooks.json;hooks/scripts/configure_gpg_signing.shis a readable reference that mirrors it. Edit the reference first, then mirror it intohooks.json. (Same pattern as theworkspace-isolationexample.)
Try it
Option 1: Load via API
Use the companionload-plugin example. Because the plugin
needs your key, pass it as a per-conversation secret:
If you have already storedgpg_keyas a user-level secret, the--secretflags are unnecessary — the hook will pick it up automatically.
Option 2: Launch via badge
Storegpg_key as a user secret first (the badge can’t carry your private key),
then click:
Note: Replace ref: main with your branch name if testing before merge.
Verifying a signed commit
Once the hook has run, any commit is signed:Hook types
SessionStart is one of several lifecycle events you can hook. See the other
examples for blocking hooks:
Related
- OpenHands Hooks Guide — repository
.openhands/hooks.jsonformat - Hooks (SDK guide) — programmatic hooks
load-plugin— programmatic plugin loading (and passing per-conversation secrets)per-conversation-secrets— how secrets reach a conversationcommand-blacklist/command-whitelist/workspace-isolation— other hook examples

