#Secrets in OpenClaw plugins?
1 messages · Page 1 of 1 (latest)
Short version: yes for secret storage, no for hard plugin-vs-agent isolation.
What exists today:
- The intended path is to declare plugin-owned secret config surfaces via
configContracts.secretInputsinopenclaw.plugin.json - Then resolve them in plugin runtime with the SDK helpers like
resolveConfiguredSecretInputString(...)/resolveRequiredConfiguredSecretRefInputString(...) - That keeps the secret out of plaintext config and lets you back it with SecretRefs (
env/file/exec)
What it does not give you:
- A hard security boundary where a native plugin can read the secret but the agent is cryptographically/process-isolated from it
- Native plugins are loaded in-process, so this is not a separate trust domain by itself
So my recommendation is:
- If “agent should not see the raw secret” is enough
- Use
secretInputs+ SecretRefs - Never put the secret in tool args, prompt context, logs, or tool results
- Expose only narrow operations, not “return secret” or arbitrary pass-through
- Use
- If you need real isolation
- Put the secret behind a separate service/process (for example an MCP/acpx server or your own backend)
- Let OpenClaw talk to that service through a narrow interface
- Keep the secret only on that external side
A good mental model is:
- SecretRefs = secret materialization/storage hygiene
- External process/service = actual isolation boundary
Relevant docs:
- https://docs.openclaw.ai/gateway/secrets
- https://docs.openclaw.ai/plugins/manifest
- https://docs.openclaw.ai/plugins/architecture
- https://docs.openclaw.ai/tools/plugin
If you want, I can sketch a minimal secretInputs manifest snippet plus the runtime resolution code for your plugin.
So an agent is theoretically able to read a SecretRefs secret, but it would need to jump through substantial hoops to do so?
Kind of, but I would not treat that as a security guarantee.
More precise version:
- The agent does not automatically get SecretRef values in its prompt/context
- SecretRefs are good for keeping secrets out of plaintext config and normal surfaces
- But for a native in-process plugin, there is no hard isolation boundary
So yes, the agent would usually need some path to get at it, for example:
- a tool/plugin action that exposes it directly or indirectly
- logs/errors that leak it
- arbitrary code execution or overly-powerful file/process access
That is why I’d describe SecretRefs as protection against accidental exposure / config leakage, not against a determined agent with the wrong capabilities.
If the requirement is really “plugin can use it, agent cannot read it”, I’d still recommend:
- SecretRef for storage/materialization hygiene, plus
- a separate process/service for the sensitive operation if you want a real boundary
So the short answer is:
- theoretically yes
- practically maybe not trivial
- architecturally not something I’d rely on for hard secrecy
If you want, I can show the “safe enough for in-process” pattern vs the “real isolation” pattern side by side.
The context is that I want to send information to a third party server about the conversation with the agent, but I don't want the agent to be able to forge entries in that conversation. I'm having the plugin register itself at installation time with a secret that it uses to communicate with this third-party.
Ah, that threat model helps.
In that setup, I’d say:
- Reasonable: plugin registers once, keeps a signing secret, and sends signed conversation events to your server
- Not enough by itself: if the agent can ask the plugin to sign arbitrary payloads
So the key design rule is:
Do not expose “sign/send this payload” as an agent-callable tool.
Instead, make the plugin send records out-of-band from trusted runtime/plugin code, and have it build the payload from data it reads itself, for example:
- session/message ids
- timestamps
- role (
user/assistant/tool) - canonical message content
- maybe sequence number + previous hash for append-only tamper evidence
Then your server verifies the signature/HMAC and rejects:
- invalid signatures
- duplicate sequence numbers
- out-of-order events
- broken hash chains
That gets you pretty far.
What it protects against:
- the agent directly inventing fake records and posting them to your server
- arbitrary unsigned traffic pretending to be your plugin
What it does not fully protect against:
- bugs or overly-powerful tool surfaces that let the agent drive the signer indirectly
- full in-process compromise, since native plugins are still in the same trust boundary
- the agent saying false things in the conversation itself, because a signed transcript can prove what was said, not that it was true
So my recommendation would be:
- Use SecretRef-backed plugin secret storage
- Keep signing/emission entirely non-tool-facing
- Sign canonical runtime-derived events
- Add sequence numbers / prev-hash
- If you want a harder anti-forgery boundary, move the signer into a separate sidecar/service/process
So, bluntly: for your use case, yes, this pattern can work, but the security comes mostly from architecture and API shape, not from SecretRef alone.
Docs for the underlying pieces:
- https://docs.openclaw.ai/gateway/secrets
- https://docs.openclaw.ai/plugins/architecture
- https://docs.openclaw.ai/plugins/manifest
If you want, I can sketch the exact “safe plugin emitter” shape I’d use here.
Goal. Let OpenClaw agents transact through our server for credential vaulting, task-scoped authorization, and human approval, while also forwarding conversation context from whatever channel OpenClaw is wired to (Slack, Discord, etc.) — the latter so future features (e.g. approval-from-observed-conversations) can consume it, and so the dashboard has audit context.
Two distinct bearer tokens (the core of the design):
Token Holder Can call
Bridge OpenClaw plugin runtime only /api/buffer/ingest, /api/plugin/agents
Agent Agent tool handlers /api/tasks, /api/gateway/, /api/feedback/
Prefixed distinctly and enforced server-side with explicit wrong-prefix rejection. A compromised agent token can’t write to the conversation buffer; a leaked bridge token can’t create tasks or execute gateway requests.
Pair flow. Plugin starts with no tokens. User generates a setup link from our dashboard and pastes it into any OpenClaw agent; the agent fetches a skill doc and deposits userId into the plugin config — the agent’s only job in setup. Plugin detects userId, posts POST /api/plugin/pair?wait=true with an install fingerprint, hostname, and the list of agent IDs OpenClaw is running, then long-polls. A single approval card appears in our dashboard — one click mints the bridge token plus one agent token per listed agent, delivered back through the plugin’s long-poll response. Adding an agent after the initial pair uses the bridge token to request a per-agent approval; the bridge itself is not re-minted.
Ingest path. Plugin forwards inbound user messages and outbound agent messages from OpenClaw’s message_received / message_sent hooks to POST /api/buffer/ingest, authenticated with the bridge token. Payloads are runtime-derived — the plugin reads content, from, conversationId / channelId, timestamp straight from the hook context, not from any agent tool argument. Messages are keyed u:{user_id}:{conversation_id} server-side. Hardening: far-future timestamps dropped; sender_name / sender_id stripped of control chars and <msg> / </msg> sequences; length-capped — defense against LLM-prompt injection via participant display names.
Dashboard. The Agents page lists active bridges with fingerprint, hostname, and timestamps, plus a per-bridge revoke button. Pending plugin pairings (initial or post-pair agent-add) surface in the same approvals feed as existing connection requests.
Known gaps we want feedback on.
Plaintext secret storage. Bridge and agent tokens currently live in the plugin’s config file (with a warn-level log fallback when api.persistConfig isn’t exposed by the SDK). Plan is to switch to OpenClaw’s SecretRef primitive so tokens land in OpenClaw’s secret store, not the config YAML — and kill the log-for-manual-paste fallback. (docs.openclaw.ai/gateway/secrets, plugins/manifest.)
No replay/reorder resistance on ingest. Each event is independent; a stolen bridge token could replay, reorder, or reinject events within the retention window. Considering per-bridge sequence_number + prev_hash so the server enforces append-only tamper evidence on the conversation log.
Same-process trust boundary. The plugin runtime is trusted not to be compromised by the LLM running inside the same OpenClaw node. A sidecar signer would harden this but is probably YAGNI for v1.
Honestly, I like the split. Bridge vs agent token is the right shape for this.
My main reactions:
-
Do not pair from
userIdalone- That is the one part I’d change.
- Have the dashboard mint a short-lived one-time pairing code (or signed setup blob), and let the agent write that into plugin config.
- The plugin redeems it once, server binds install ↔ user, then expires it.
userIdby itself is too easy to misbind.
-
Keep conversation ingest fully outside agent-callable tool paths
/api/buffer/ingestshould only ever be called from a passive plugin/runtime observer path- never from “agent asks plugin to send X”
- that is what actually gives you anti-forgery for the audit stream
-
Treat hostname / install fingerprint as hints, not identity
- useful for UX and approval display
- not strong auth
- if you want stronger install identity later, give the install a local keypair and bind it at pair time
-
Make pairing/idempotency very boring
- long-poll is fine
- but retries should not mint multiple bridges or multiple agent-token sets
- use a stable pairing request id / idempotency key
-
For the audit stream, include provenance
- channel
- session/thread/message ids
- role (
user,assistant,tool, system-ish event) - sequence number, maybe
prevHash - then future approval/audit features have solid footing
So if I were tightening this design, I’d make the flow:
- dashboard generates pairCode
- user pastes it into OpenClaw
- agent writes only
pairCode(non-secret, one-time) - plugin redeems
pairCode - dashboard approval returns:
- bridge token for passive ingest/plugin management
- per-agent tokens for tool handlers
- bridge token never touches tool/runtime paths that the model can drive directly
That gives you the cleanest separation.
The one sentence summary is:
Your token split looks good, but the real security boundary comes from who is allowed to invoke which code path, not just from where the secret is stored.
If you want, I can do a quick red-team pass on this flow and point out the top 3 failure modes I’d watch for.
This is pretty thoughtful. My priority feedback would be:
1) Small but important correction on SecretRefs
I would not think of SecretRef as “OpenClaw’s secret store”.
SecretRefs are basically indirections to a backing source (env / file / exec), not a built-in encrypted vault that magically stores runtime-minted tokens for you.
So for bridge/agent tokens minted after pairing, I’d recommend:
- kill the log/manual-paste fallback immediately
- store them in a plugin-owned 0600 file or a file-backed SecretRef provider
- then reference that from plugin config via
configContracts.secretInputs
That’s much better than plaintext config, but it’s still your backing store choice, not an internal OpenClaw vault.
2) For ingest integrity, do event_id + seq first
I like the idea of sequence_number + prev_hash, but if I were staging it:
v1 hardening
bridge_idevent_id(stable idempotency key)- monotonic
seq - server receive timestamp
- reject duplicate
seqwith different body - accept exact replay of same
event_idas no-op
v2
prev_hashfor tamper-evident chaining
Why: seq + idempotency buys you more operational safety earlier. prev_hash is great, but it makes recovery/retry logic a bit more annoying.
3) A stolen bridge token is still bad
seq / prev_hash help with replay/reorder/tamper evidence, but they do not really solve “attacker has live bearer and can keep appending”.
So if that threat matters, I’d want one of:
- short-lived bridge tokens + refresh
- or bridge bound to a local install keypair
- plus the revoke button you already have
4) Same-process trust boundary is fine for v1, if you say it plainly
I think your “probably YAGNI for v1” call is reasonable.
I’d just be explicit that the trust model is:
- native plugin runtime is trusted
- model/agent is not trusted
- no hard process boundary yet
That is honest and defensible for v1.
5) Your prompt-injection hardening is good, but structure matters more than scrubbing
Stripping control chars and fake tags is good hygiene.
But the stronger defense is:
- keep the audit stream structured
- preserve provenance fields separately
- escape on render
- and if future LLM features consume it, feed structured records, not a flattened blob
So overall: good architecture, with the biggest immediate fix being the storage model and the biggest protocol fix being idempotent sequencing before fancy chaining.
If you want, I can sketch the exact bridge-token storage shape I’d use for a native OpenClaw plugin, because that’s the part I’m most wary of here.
Findings on message_sent hook availability for non-channel plugins
The message_sent hook is advertised in OpenClaw's public plugin hook catalog (PLUGIN_HOOK_NAMES in the SDK's hook-types.ts), alongside message_received and message_sending. Empirically, however, non-channel plugins registered via definePluginEntry — the helper OpenClaw documents for "provider, tool, command, service, memory, and context-engine" plugins — never receive message_sent callbacks. We confirmed this across two distinct channel backends (webchat and Telegram) in OpenClaw 2026.4.16: in both cases our plugin's synchronously-registered api.on("message_sent", ...) and api.on("message_sending", ...) handlers stayed silent across dozens of agent replies, while api.on("message_received", ...) fired reliably with full content and conversation identifiers (convo=webchat, convo=telegram:<chat_id>), and api.on("reply_dispatch", ...) fired with routing-only metadata (session keys, send policy, no text). Reading the SDK dispatch code (dispatch-*.js) makes the gap plausible: the outbound-reply codepath appears to route through defineChannelPluginEntry's channel capability rather than the plugin-level hook runner that non-channel plugins are wired into — so message_sent in the catalog is effectively reserved for plugins that own outbound delivery, not for plugins that want to passively observe it. Practically, this means sidecar-style plugins (ours, and anything similar that wants a live transcript of both sides of the conversation) can only capture inbound messages via message_received. For our auto-approval use case that's adequate because the approval LLM only honors role="user" messages anyway, but it's a real limitation for any future feature that needs agent-side context — those would need either a genuine defineChannelPluginEntry registration that hooks outbound, or a new SDK mechanism for non-channel observers.
This is a really useful finding, and after checking the docs/source I don’t think this is intended behavior.
Why I say that:
- Docs describe
message_received / message_sending / message_sentas generic plugin hooks, not channel-plugin-only hooks: definePluginEntry(...)is explicitly the normal entrypoint for non-channel plugins- In source, non-channel plugins register hooks into the same global hook runner as everything else
- Outbound delivery in
src/infra/outbound/deliver.tscalls that global runner directly for both:message_sendingmessage_sent
So the current code shape looks like:
- intended contract: any loaded plugin hook should be able to observe outbound sends
- what you observed on 2026.4.16: some outbound reply paths are not actually going through that shared hook emission path
The strongest signal here is actually message_sending:
- that hook is inline and can cancel/modify delivery
- if it never fires either, that suggests a bypass/regression in the outbound delivery path, not just a missing passive observer callback
Also, current tests seem to cover the hook runner itself, but not this exact end-to-end case, so I can totally see how this slipped through.
So I’d treat your conclusion as:
Bug/regression, not a documented “reserved for channel plugins” limitation.
I’d recommend filing a GitHub issue with basically the writeup you pasted here. Good title:
message_sending / message_sent hooks do not fire for definePluginEntry plugins on outbound replies
Useful references to include:
src/plugin-sdk/plugin-entry.tssrc/plugins/registry.ts
src/infra/outbound/deliver.tssrc/plugins/hook-types.tssrc/plugins/wired-hooks-message.test.ts
And your repro is already strong:
- version:
2026.4.16 - plugin type:
definePluginEntry - channels tested: webchat + Telegram
message_receivedworksreply_dispatchfiresmessage_sending/message_sentsilent
If you want maintainer attention on it, GitHub issues is the right path: https://github.com/openclaw/openclaw/issues
I see a recently closed issue that matches what I'm seeing: https://github.com/openclaw/openclaw/issues/21789
Any idea why it's not fixed in a fresh openclaw install?
Yeah, and that’s the weird part: I checked the repo, and the old message_sent fix is not new. There’s a fix commit for it (1d8bda4a21, “emit message_sent hook for all successful outbound paths”), and it’s already contained in releases as old as v2026.4.1.
So if you’re truly running a fresh 2026.4.16 gateway, my guess is one of these:
-
CLI version != running gateway version
- very common
- the binary you installed and the daemon/service actually serving messages can be different
-
Old service still running
- especially if you upgraded but didn’t fully restart/reinstall the gateway service
-
This is a different regression
- especially since you’re seeing both
message_sendingandmessage_sentstay silent - that suggests a path bypass, not just the old missing
message_sentcall
- especially since you’re seeing both
Can you paste these two first?
openclaw --version
openclaw gateway status
If those show a real fresh 2026.4.16 runtime, then I’d treat this as not “the old issue wasn’t fixed”, but a new regression or an outbound path that still bypasses the shared hook emitter.
If you want, after you paste those, I can help interpret them quickly.
OpenClaw 2026.4.16
🦞 OpenClaw 2026.4.16 (unknown) — You had me at 'openclaw gateway start.'
│
â—‡
Service: systemd (disabled)
File logs: /tmp/openclaw/openclaw-2026-04-18.log
Config (cli): ~/.openclaw/openclaw.json
Config (service): ~/.openclaw/openclaw.json
Gateway: bind=lan (0.0.0.0), port=18789 (env/config)
Probe target: ws://127.0.0.1:18789
Dashboard: http://172.21.0.2:18789/
Probe note: bind=lan listens on 0.0.0.0 (all interfaces); probing via 127.0.0.1.
Runtime: unknown (systemctl not available; systemd user services are required on Linux.)
RPC probe: ok
systemd user services unavailable.
systemd user services are unavailable; install/enable systemd or run the gateway under your supervisor.
If you're in a container, run the gateway in the foreground instead of openclaw gateway.
Troubles: run openclaw status
Troubleshooting: https://docs.openclaw.ai/troubleshooting
Thanks, that helps.
Given that output, I don’t think the answer is “you’re on an old systemd service and the fix never landed.” Your CLI/runtime looks current enough that the old #21789 explanation no longer fits cleanly.
My best read now is:
- #21789 was closed because the original bug really was fixed
- back then,
message_sentgenuinely had no outbound caller
- back then,
- what you’re seeing on 2026.4.16 is probably a different bug
- likely a reply path that still bypasses the shared outbound hook emitter
- and the fact that
message_sendingis also silent makes that even more likely
That’s the key distinction:
- old bug:
message_sentmissing - your bug: both outbound hooks silent, while
message_receivedandreply_dispatchstill work
So I’d frame it as:
same symptom family, but probably not the same root cause
If you want one more quick check before filing, I’d look at the log for hook registration and outbound delivery:
rg -n "hook runner initialized|message_sent|message_sending|deliverOutboundPayloads" /tmp/openclaw/openclaw-2026-04-18.log
But honestly, you already have enough for a solid new issue. I’d open it as something like:
Outbound reply paths in 2026.4.16 bypass message_sending / message_sent for definePluginEntry plugins
And mention:
- #21789 looks related, but this repro is on 2026.4.16
message_receivedworks
reply_dispatchworksmessage_sendingandmessage_sentboth stay silent- reproduced on webchat + Telegram
- plugin is non-channel via
definePluginEntry
That feels like a legit regression report, not user error.
Could you write the full content of the issue so I'm not misstating things?
Yep. I’d paste something like this:
Title: Outbound `message_sending` / `message_sent` hooks do not fire for `definePluginEntry` plugins on 2026.4.16
## Summary
For a non-channel plugin registered via `definePluginEntry`, inbound hook delivery works, but outbound message hooks do not appear to fire.
In my testing on OpenClaw 2026.4.16:
- `message_received` fires reliably
- `reply_dispatch` fires reliably
- `message_sending` does not fire
- `message_sent` does not fire
This was reproduced across two different channel backends (webchat and Telegram).
## Expected behavior
A plugin registered via `definePluginEntry` should be able to observe the generic outbound message hooks advertised by the SDK/docs:
- `message_sending`
- `message_sent`
The docs describe these as generic plugin hooks, not channel-plugin-only hooks.
## Actual behavior
For a non-channel plugin:
- inbound messages arrive via `message_received`
- outbound replies are visible only indirectly through `reply_dispatch` routing metadata
- no `message_sending` callback is observed
- no `message_sent` callback is observed
In practice this means sidecar/passive-observer plugins can capture inbound user messages, but not the agent's outbound replies.
## Repro
I registered a non-channel plugin with `definePluginEntry` and synchronously attached:
```ts
api.on("message_received", ...)
api.on("message_sending", ...)
api.on("message_sent", ...)
api.on("reply_dispatch", ...)
Then I exercised normal replies through:
- webchat
- Telegram
Observed behavior across dozens of replies:
- `message_received` fired with full content and conversation ids
- `reply_dispatch` fired with routing/session metadata only
- `message_sending` never fired
- `message_sent` never fired
## Why this seems like a bug rather than intended behavior
I checked the current docs/source and they suggest these hooks are intended for general plugins, not just channel plugins.
Relevant docs:
- `docs/concepts/agent-loop.md`
- `docs/plugins/building-plugins.md`
Relevant source:
- `src/plugin-sdk/plugin-entry.ts`
- `src/plugins/registry.ts`
- `src/plugins/hook-types.ts`
- `src/plugins/hooks.ts`
- `src/infra/outbound/deliver.ts`
What I found:
1. `definePluginEntry(...)` is the documented entrypoint for non-channel plugins.
2. Non-channel plugins register hooks into the same global hook runner.
3. `infra/outbound/deliver.ts` appears to invoke both `message_sending` and `message_sent` through that global hook runner.
Because **both** outbound hooks are silent, this looks less like “`message_sent` is channel-plugin-only” and more like some outbound reply paths in 2026.4.16 are bypassing the shared outbound hook emission path.
## Related issue
This looks related to #21789, but I do not think it is the exact same bug.
My understanding is:
- #21789 described an older case where `message_sent` had no outbound caller
- on 2026.4.16, the source now contains outbound hook emission code
- however, in practice, non-channel plugins still do not see outbound hooks on the tested paths
So this may be a regression or an unhooked outbound path rather than the original missing-call-site bug.
## Impact
This blocks passive/sidecar-style plugins that need a live transcript of both sides of the conversation.
For my immediate use case, inbound-only capture is sufficient because downstream approval logic only reads `role="user"` messages.
But for any plugin that needs full transcript or assistant-side audit context, this is a real limitation.
## Environment
- OpenClaw: `2026.4.16`
- Linux