Skip to main content

View source on GitHub

Configure GPG commit signing at the start of every conversation — not just when a repository is selected — using a 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:
But 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 --global instead of a bare git config. There is no repository yet when SessionStart fires, and a global config applies to every repo cloned afterwards.
  • Never fails the session. SessionStart hooks cannot block, so on any problem (missing secret, bad key) the hook logs the reason to /tmp/openhands_gpg_setup.log and exits 0.

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 the GPG_KEY_SECRET_NAME, GIT_USER_NAME_SECRET_NAME, and GIT_USER_EMAIL_SECRET_NAME environment variables if your org uses different names.

What’s in the box

The hook

The logic lives in hooks/hooks.json under the SessionStart event:
How it works:
  1. SessionStart — runs once, when the conversation begins (no repo needed).
  2. Reads the armored key from $gpg_key, imports it with gpg --batch --import.
  3. Extracts the primary key fingerprint (gpg --show-keys --with-colons).
  4. Sets user.signingkey, commit.gpgsign true, tag.gpgsign true, and gpg.program globally, plus the optional identity secrets.
  5. Exits 0 always; 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 in hooks/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 — so command cannot point at a bundled hooks/scripts/*.sh. The runnable copy is therefore inlined in hooks.json; hooks/scripts/configure_gpg_signing.sh is a readable reference that mirrors it. Edit the reference first, then mirror it into hooks.json. (Same pattern as the workspace-isolation example.)

Try it

Option 1: Load via API

Use the companion load-plugin example. Because the plugin needs your key, pass it as a per-conversation secret:
If you have already stored gpg_key as a user-level secret, the --secret flags are unnecessary — the hook will pick it up automatically.

Option 2: Launch via badge

Store gpg_key as a user secret first (the badge can’t carry your private key), then click: Try GPG Signer
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: