#Secrets in OpenClaw plugins?

1 messages · Page 1 of 1 (latest)

molten dock
#

Hi there! I'm building a plugin for OpenClaw that should contain a secret that the plugin is able to read but that the agent should not have access to. Is there a blessed mechanism to enable this today? What do you recommend?

limpid sparrowBOT
chilly hornetBOT
#

To help others find answers, you can mark your question as solved via Right click solution message -> Apps -> âś… Mark Solution

limpid sparrowBOT
gloomy oceanBOT
#

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.secretInputs in openclaw.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:

  1. 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
#
  1. 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:

If you want, I can sketch a minimal secretInputs manifest snippet plus the runtime resolution code for your plugin.

molten dock
#

So an agent is theoretically able to read a SecretRefs secret, but it would need to jump through substantial hoops to do so?

gloomy oceanBOT
#

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.

molten dock
#

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.

gloomy oceanBOT
#

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:

  1. Use SecretRef-backed plugin secret storage
  2. Keep signing/emission entirely non-tool-facing
  3. Sign canonical runtime-derived events
  4. Add sequence numbers / prev-hash
  5. 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:

If you want, I can sketch the exact “safe plugin emitter” shape I’d use here.

molten dock
#

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.

gloomy oceanBOT
#

Honestly, I like the split. Bridge vs agent token is the right shape for this.

My main reactions:

  1. Do not pair from userId alone

    • 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.
    • userId by itself is too easy to misbind.
  2. Keep conversation ingest fully outside agent-callable tool paths

    • /api/buffer/ingest should 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
  3. 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
  1. 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
  2. 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_id
  • event_id (stable idempotency key)
  • monotonic seq
  • server receive timestamp
  • reject duplicate seq with different body
  • accept exact replay of same event_id as no-op

v2

  • prev_hash for 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.

molten dock
#

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.

gloomy oceanBOT
#

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_sent as 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.ts calls that global runner directly for both:
    • message_sending
    • message_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.ts
  • src/plugins/registry.ts
#
  • src/infra/outbound/deliver.ts
  • src/plugins/hook-types.ts
  • src/plugins/wired-hooks-message.test.ts

And your repro is already strong:

  • version: 2026.4.16
  • plugin type: definePluginEntry
  • channels tested: webchat + Telegram
  • message_received works
  • reply_dispatch fires
  • message_sending / message_sent silent

If you want maintainer attention on it, GitHub issues is the right path: https://github.com/openclaw/openclaw/issues

molten dock
gloomy oceanBOT
#

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:

  1. CLI version != running gateway version

    • very common
    • the binary you installed and the daemon/service actually serving messages can be different
  2. Old service still running

    • especially if you upgraded but didn’t fully restart/reinstall the gateway service
  3. This is a different regression

    • especially since you’re seeing both message_sending and message_sent stay silent
    • that suggests a path bypass, not just the old missing message_sent call

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.

molten dock
#

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

gloomy oceanBOT
#

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_sent genuinely had no outbound caller
  • 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_sending is also silent makes that even more likely

That’s the key distinction:

  • old bug: message_sent missing
  • your bug: both outbound hooks silent, while message_received and reply_dispatch still 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_received works
#
  • reply_dispatch works
  • message_sending and message_sent both stay silent
  • reproduced on webchat + Telegram
  • plugin is non-channel via definePluginEntry

That feels like a legit regression report, not user error.

molten dock
#

Could you write the full content of the issue so I'm not misstating things?

gloomy oceanBOT
#

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