Skip to content

Add an OpenClaw adapter - #502

Open
President wants to merge 22 commits into
GenAI-Security-Project:integrationfrom
disciplinedware:refimpl/openclaw-adapter
Open

President wants to merge 22 commits into
GenAI-Security-Project:integrationfrom
disciplinedware:refimpl/openclaw-adapter

Conversation

@President

Copy link
Copy Markdown

What changed

Adds adapters/openclaw/, an OpenClaw plugin that sends the agent's steps to an ACS Guardian and applies its decisions. It builds on #229; until #229 merges, this diff includes its commits.

Which issue does this implement

Closes #287

Base branch

  • integration, because this touches the specification, schemas, a reference
    implementation, an adapter, tests, or CI

Type of change

  • Reference implementation or adapter

I tested this

  • I synced my branch with the base branch before opening this
  • uv run pytest -v passes on my machine
  • uv run mkdocs build --strict passes on my machine

Checklist

  • Commits are signed off with git commit -s (required by the DCO)
  • Prose follows STYLE.md
  • No secrets, tokens, or internal URLs in the diff

Security

The adapter is a control point: it blocks what the Guardian denies. adapters/openclaw/mapping.md lists the paths OpenClaw gives no hook for.

jrcrittenden and others added 19 commits October 16, 2026 19:00
Signed-off-by: Jason Crittenden <jrcrittenden@gmail.com>
- Reject non-finite timestamp drift so a leap-second timestamp
  (23:59:60, which date-time accepts and Date.parse returns NaN for)
  no longer passes the §10.3 skew window or lands in the replay store
  with a NaN expiry.
- Commit each ContextEntry to its request content via request_hash,
  the lowercase-hex SHA-256 of the JCS-canonicalized params
  (context-entry.json, RECOMMENDED).
- README: mark the handshake as not yet enforced (-32003) and the hook
  set as partial (subagentStart and agentTrigger return -32011).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jason Crittenden <jrcrittenden@gmail.com>
- Guardian: record each session's ClientHello methods_implemented and
  refuse with -32003 CAPABILITY_NOT_NEGOTIATED any step from a session
  that never sent handshake/hello, or for a method it did not declare
  (§4, §17.1). system/ping stays exempt. The check runs before the replay
  store remembers the request_id, so a retry after re-handshaking is not
  a false replay. The record is bounded (LRU, 4096 sessions).
- Hosts: on -32003, governStep repeats the handshake once and retries
  with a fresh request, so a Guardian restart does not deny every step
  until the session ends. Only a decision replaces the refusal; a failed
  re-handshake or an undelivered retry keeps the -32003.
- Conformance probes and tests now handshake before posting steps.
- README: handshake row is implemented; replay protection names the
  remaining gap that the in-memory store does not survive a restart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jason Crittenden <jrcrittenden@gmail.com>
A full replay-protection cache answered a brand-new request with -32005
REPLAY_DETECTED. §17.1 recovers from -32005 by regenerating request_id,
which cannot succeed while the cache is full. Return -32000
SESSION_REFUSED with data.reason "replay_protection_capacity" instead:
it is in the §17.1 registry, so every ACS client fails closed on it.

startGuardian takes an optional replayProtectionStore so a server test
can fill the cache.

Reported by @shivang-sec on GenAI-Security-Project#229.

Signed-off-by: Jason Crittenden <jrcrittenden@gmail.com>
The HMAC signing block in handleAcsRequest ran outside any try, on
unauthenticated input. A session_id over 1024 bytes makes HKDF throw, and
a deeply nested body overflows the stack in JCS canonicalization. Either
escaped handleAcsRequest as an HTML 500, which a host reads as a hook
that never fired. The block is now wrapped. A request that throws there
cannot carry a valid signature, so its response goes out unsigned, like
any other unauthenticated request's, and the failure is reported on
stderr. The envelope log records the same response it did before, minus
the signature it could never have had.

Every AcsResult now carries metadata.evaluator "deterministic", so the
trace exporter in GenAI-Security-Project#244 reads the evaluator from the wire instead of
assuming it.

Both raised by @rocklambros on GenAI-Security-Project#229.

Signed-off-by: Jason Crittenden <jrcrittenden@gmail.com>
Request-signature verification canonicalises the unauthenticated body
before computing the HMAC. A signed body nested past the stack made that
throw, and the throw fell through dispatch to the outer net, which
answers -32020. That code is outside the section 17.1 registry, so a
client branching on registered codes could fail open on it.
verifyHmacSha256 now treats an input it cannot canonicalise as an
invalid signature, so the request gets -32004 SIGNATURE_INVALID like any
other unauthenticated one. The signing-boundary test now asserts the
answer for each of its two inputs.

Raised by @astrogilda on GenAI-Security-Project#229.

Signed-off-by: Jason Crittenden <jrcrittenden@gmail.com>
With HMAC on, two host paths turned a deny into an allow.

The client threw on any unverifiable response before reading its code,
so a refusal the Guardian cannot sign (oversize body, -32700, a bad
request signature) became an "unknown" failure and the proceed posture
ran the step. A refusal-coded error with no result is now passed through
unverified: it can only fail the step closed. The refusal codes move to
failure-kinds.ts so the client can read them without an import cycle.

A request too deep to canonicalise threw inside request building and the
posture answered it. Signing is now its own "sign" stage whose failure,
kind "unsignable", blocks regardless of posture.

Signed-off-by: Jason Crittenden <jrcrittenden@gmail.com>
A protocols/MCP/tools/call payload carrying both tool/arguments and
params was evaluated on the former while a forwarding server runs the
latter. Both shapes together are now refused as envelope_invalid, and
{value} is unwrapped only in the ACS shape.

The stock rules skip a policy target that is not a string, so an object
or array command was allowed on any tool-call path. The snapshot
assembler now throws on one, which denies with evaluation_failed.

Signed-off-by: Jason Crittenden <jrcrittenden@gmail.com>
The host listed the refusal codes it knew, so a code added later, such
as the retryable overload code Discussion GenAI-Security-Project#257 proposes, was classified
error_without_decision and the proceed posture ran the step. The host now
treats -32700 and the whole -32000..-32099 range as a refusal, both when
it accepts an unsigned refusal and when it classifies one. Codes outside
the range, like -32601 or -32603, still keep the posture.

Signed-off-by: Jason Crittenden <jrcrittenden@gmail.com>
…he MCP method

handshake/hello, which GenAI-Security-Project#229's Guardian refuses with -32003. postRaw and
the generator now send one first; the hello gets no span.

protocols/MCP/tools/call is evaluated but otel-mapping.json maps only
steps/* and agbom/* methods, so it is named in METHODS_WITHOUT_SPAN with
that reason until the spec gives it a step_to_span entry.

The decision event now carries acs.evaluator, and the response carries a
chain_hash that covers each entry's recorded time. The two tests that
compare a Guardian with export on and off give both the same clock, and
the sample comparison sets the hash's value aside with the timestamps.
The committed sample is regenerated.

Signed-off-by: Jason Crittenden <jrcrittenden@gmail.com>
…example

The sample trace was regenerated under GenAI-Security-Project#229's Guardian, whose decision
event now carries acs.evaluator and acs.chain_hash. The example on the
page is checked against the sample, so it is copied again, and the
sentence that lists the event's contents names both.

Signed-off-by: Jason Crittenden <jrcrittenden@gmail.com>
PR GenAI-Security-Project#272 defines key_id for HMAC as an opaque label from the deployment's
configuration that names one secret. This tree used the session_id as
key_id and rejected any other value.

The Guardian now requires ACS_HMAC_KEY_ID beside ACS_HMAC_SECRET, with
no default, and verifies a request with the secret its key_id names. An
unknown label is SIGNATURE_INVALID, unsigned, and is never tried against
another secret. A response is signed with the secret, and names the
key_id, of the request it answers.

Both hosts read the same pair, sign with the configured label, and
reject a response that names another. Setting only one of the two is a
configuration error. The OpenCode plugin raises it from each hook rather
than from its factory, because a factory throw unloads the plugin and
leaves every tool ungoverned.

Signed-off-by: Jason Crittenden <jrcrittenden@gmail.com>
Brings in GenAI-Security-Project#272 and GenAI-Security-Project#256. Both sides are kept in every conflict:

- hashEntry keeps the section 11 JCS content hash and adds findings and
  request_id_ref to the content only when present, so an entry without
  findings hashes as before and one with them is committed to them.
- dispatch and evaluateStep take both this branch's replay store,
  negotiated sessions, registry and keys and GenAI-Security-Project#272's envelope log and
  findings. The replay check still refuses a repeat before examineStep,
  so request_id_duplicate fires only for a repeat it missed.
- governStep keeps the re-handshake retry and adds GenAI-Security-Project#272's one-time retry
  on REPLAY_DETECTED, signed like any other request.
- GenAI-Security-Project#272's request_id_ref tests handshake first, and its duplicate tests
  stand in a replay store that lets the repeat through; a new test pins
  that a repeat inside the window gets -32005 and no finding.

Signed-off-by: Jason Crittenden <jrcrittenden@gmail.com>
Brings in GenAI-Security-Project#309, GenAI-Security-Project#273, GenAI-Security-Project#168 and GenAI-Security-Project#323. GenAI-Security-Project#309 moves a request the Guardian
rejects before trusting it off the step spans, and leaves dispatch a
`rejection` to report for this PR's checks. This merge wires them in:

- dispatch keeps this PR's checks and returns them as Dispatched. A
  SIGNATURE_INVALID, TIMESTAMP_OUT_OF_WINDOW or REPLAY_DETECTED refusal
  reports signature_invalid, timestamp_out_of_window or replay_detected,
  and a payload that fails validation reports envelope_invalid, as it
  did in GenAI-Security-Project#309.
- validateForDispatch checks the envelope's shape only. The method
  payload is still validated inside dispatch after the signature, so an
  unsigned request with a malformed payload is refused as
  unauthenticated, not answered with a trusted deny.
- A method with no step span (handshake/hello, system/ping) opens no
  span, so a rejection dispatch reports for one is recorded as a
  rejected request when the response is sent.
- The "until PR GenAI-Security-Project#229" notes from GenAI-Security-Project#309 are gone, the sample trace is
  regenerated, and the denied span is copied into the OpenTelemetry
  page. GenAI-Security-Project#309's "no span for handshake/hello" test sends a schema-valid
  ClientHello, because an invalid one is now a rejected request.
- New end-to-end tests: a replayed request, an unsigned request and a
  forged hello are each recorded apart, and the genuine request keeps
  the only span under its ids.

Signed-off-by: Jason Crittenden <jrcrittenden@gmail.com>
…hello from failing open

Three places where the keyed host still failed open under `proceed`, from
the 10-29 review:

- A reply whose signature fails verification, or that answers another
  request (its JSON-RPC id or result.request_id does not match), is now
  its own failure kind, signature_invalid. It blocks the step whatever
  the posture, as issue GenAI-Security-Project#308 rules, and becomes an acs.decision.failure
  with class signature_invalid. A stripped signature, a wrong key and a
  replayed earlier allow each end in deny.
- The one retry after REPLAY_DETECTED replaces the refusal only when a
  decision arrives, as the -32003 retry already does. A retry that times
  out leaves the refusal standing.
- A verified ServerHello that this signed host rejects for lacking
  acs-core or HMAC-SHA256 keeps the posture it declared. A hello that
  fails verification carries no posture this host can trust, so every
  failure in that session blocks instead of falling to the ACS default.

The Guardian README says why -32003 and the -32000 capacity refusal are
recorded under the request's ids: both come after the signature.

The tree README now carries the three lines asked for on 10-13: the
agent's shell can read the secret with printenv, neither host sends the
rest of the minimum hook set, and the section 10.3 nonce is not checked.

Signed-off-by: Jason Crittenden <jrcrittenden@gmail.com>
PR GenAI-Security-Project#334 makes the Guardian answer -32600 Invalid Request for a JSON-RPC
frame it cannot read. That code sat outside the refusal range, so the
host applied its posture, and under `proceed` the step ran: on a keyless
host, `rm -rf /` came back allow. isRefusalCode now counts -32600 with
-32700 and the -32000..-32099 range, so the step blocks.

Signed-off-by: Jason Crittenden <jrcrittenden@gmail.com>
…claw-adapter

Signed-off-by: Konstantin Trunin <konstantin@trunin.com>
Add an OpenClaw plugin in adapters/openclaw/ that converts a stock
OpenClaw agent's hooks into signed ACS steps and each decision back into
the hook's result, before the agent acts (issue GenAI-Security-Project#287). It builds on the
TypeScript reference's host-adapter for signing, verification and
decision checks.

- Sends sessionStart, agentTrigger or userMessage, turnStart, tool calls
  and results, every reply delivery, turnEnd and sessionEnd, in direct
  and group chats, each with the person it acts for.
- Puts an ask to a person as an OpenClaw approval: the tool's name as
  its title, the Guardian's question as its description.
- Knows no tool or channel by name and checks none of OpenClaw's
  settings. A hook OpenClaw does not give is not caught, and mapping.md
  lists each gap, subagentStart included.
- Blocks a tool call, user message or reply that OpenClaw gives too few
  facts to send, since the Guardian never sees it; a tool result it
  cannot send follows the failure posture (§6.4).
- Keeps a session the Guardian refused, at the handshake or at
  sessionStart, refused in every registration of the plugin.
- Sends system/ping for liveness and each session's decision failures
  in sessionEnd's summary.
- Tested against the TypeScript and Go reference Guardians over HTTP and
  inside a running OpenClaw gateway with a stub model.

Signed-off-by: Konstantin Trunin <konstantin@trunin.com>

Claude-Session: https://claude.ai/code/session_01NFeGFJ43dFKjVrt8bueKfa
@President
President requested a review from Lrd0036 as a code owner November 4, 2026 23:11
@github-project-automation github-project-automation Bot moved this to Needs triage in ACS Project Tracker Nov 4, 2026
The OpenClaw image writes its log as uid 1000, so a test that runs as
another user, as on GitHub's runners, cannot read it or delete its
scratch folder. Run the container as the test's uid and gid, and drop
the chmod 777 the old setup needed.

Test ASK on the Go Guardian too: it names the approver through
policy.ask_approver and sends the ASK with policy.ask_substitution
none, since OpenClaw routes an approval to a person.

Signed-off-by: Konstantin Trunin <konstantin@trunin.com>

Claude-Session: https://claude.ai/code/session_01NFeGFJ43dFKjVrt8bueKfa
@acs-board-reconciler acs-board-reconciler Bot moved this from Needs triage to In progress in ACS Project Tracker Nov 5, 2026

@Lrd0036 Lrd0036 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested at 76e8fd3 against OpenClaw 2026.10.9, at the hook level and on a live gateway. Each live finding reproduced in 3 of 3 runs. The line notes are inline.

The main risk: the adapter does not always run last, so the gateway can run a call the Guardian rewrote with the parameters the Guardian replaced. README.md:76 already tells operators not to run another plugin that returns params. Findings 1 and 2 are about the reason it gives and a case it doesn't name.

What holds up. At the hook level, a deny never sends a message, deny and cancel win in either load order, and a config reload fails closed. In OpenClaw's merge code for before_tool_call and message_sending, block and cancel are sticky and stop the handler chain, so this does not depend on load order.

Findings

1. The adapter does not always run last. README.md:76 says the plugin registers every hook "at the lowest priority", so OpenClaw keeps its params and reply text. Number.MIN_SAFE_INTEGER is not the lowest priority OpenClaw accepts. OpenClaw does not validate priority, and it breaks ties by load order. A test plugin at -Infinity replaced the params the Guardian approved in 3 of 3 live runs. At the hook level, it wins in either load order.

2. A second copy of this adapter undoes the first one's modify. OpenClaw gives each before_tool_call handler a copy of the original event, so each copy decides on the original call. The later copy's allow returns the original params, and OpenClaw keeps the last params returned. Load order decides which Guardian wins. In 3 of 3 live runs, the gateway ran the original command instead of Guardian A's rewrite. A deny still wins in either order.

OpenClaw loads one plugin per id and dropped an unmodified second copy. The live test used two copies of this plugin with only the id renamed (acs-org, acs-team), in openclaw.plugin.json, PLUGIN_ID and package.json. Both were listed in plugins.load.paths, each pointing at its own Guardian. An operator who runs two Guardians would do that, and would not read the README's "another plugin" rule as covering a second copy of this one. Unlike the two cases the README says a plugin cannot detect, this one can be detected, because both copies run in the gateway's process.

3. A Guardian that starts a run on this gateway sets off a chain of nested runs. In the test, the Guardian was a scripted fake built from tests/fake-guardian.ts. Before answering a userMessage, it sent an A2A message to the same gateway and waited for the reply, so the gateway's own agent acted as its judge. Each query opened a new session, and deadlineMs was 3000. With OpenClaw's A2A rate limit off (rateLimitPerMinute: 0), there were 47–49 nested runs in 40 s, and the count had not stopped rising. Every step failed closed, so nothing ran that the Guardian refused. The cost is the runaway runs. The default limit, 30 requests a minute per peer, stopped the chain at 30.

4. exec can read the HMAC secret in the demo setup. OpenClaw's host exec inherits the gateway's environment, minus a fixed list of known credential names such as AWS keys and GITHUB_TOKEN. ACS_HMAC_SECRET is not on that list. hmacSecretFile does not help on the host, because exec can read any file the gateway can. I verified the read, not a forged envelope. Signing one as the adapter takes only the secret and the key_id, and the demo config file holds the key_id.

5. A message the agent sends to its own gateway looks like another message from the same peer. It reached the Guardian with the same user_context as the original, and nothing linked it to the run that sent it, so policy cannot bound the depth. In the test, a script run through the agent's exec tool read A2A_DEMO_TOKEN from its environment and used it to message the gateway. The token reaches exec the same way as the secret in finding 4. The test supplied the script, because the stub model makes no choices, so this shows what the agent's tool can do, not what a model chose to do.

Suggested fixes

All untested.

  • Detect a second copy at registration, for example with a marker on globalThis, and block every step when one is found (finding 2).
  • Stop sending after repeated deadline expiries, so a Guardian whose decisions keep timing out ends its own chain (finding 3).
  • Report A2A inbound as agentTrigger with trigger_type: "a2a_inbound" (finding 5).
  • In the README, correct the priority claim, support one copy per gateway, and say how to keep the secret and the A2A token out of the agent's reach. OpenClaw appears to skip the host environment for sandboxed exec, which may be the answer.

api.registerTrustedToolPolicy does not fix finding 1. Trusted policies run before the before_tool_call hooks, and a later hook's params still win. OpenClaw locks the parameters only after a trusted policy has asked for approval and received it.

The spec does not say whether more than one adapter or Guardian may govern one host. That deserves its own issue rather than a fix here.

How this was tested
  • Hook level: the plugin's own register(), loaded twice into OpenClaw's createHookRunner.
  • Live: OpenClaw 2026.10.9 from npm in a node:24 container, with the demo config and stub model. The pinned ghcr.io image was not reachable from my environment.
  • Guardians: tests/fake-guardian.ts. Its system/ping signature errors are a limit of the fake Guardian.
Repro: a second adapter undoes the first one's modify

Save as adapters/openclaw/tests/two-adapters.repro.test.ts and run bun test tests/two-adapters.repro.test.ts. It passes, which means the gateway runs the original command. With Guardian A's copy alone, or with the load order reversed, the rewrite runs. Both Guardians receive the original command.

// Two copies of this plugin on one gateway, run through OpenClaw 2026.10.9's own hook runner.
// Guardian A rewrites the command; Guardian B allows. OpenClaw runs B's (original) params.
import { expect, it } from "bun:test";
import plugin from "../acs-plugin.ts";
import { startFakeGuardian } from "./fake-guardian.ts";
// The hashed chunk name is specific to OpenClaw 2026.10.9.
import { t as createHookRunner } from "../node_modules/openclaw/dist/hooks-CgZHhfMh.mjs";

const KEY = { keyId: "k", secret: Buffer.from("0123456789abcdef0123456789abcdef") };
process.env.ACS_REPRO_SECRET = KEY.secret.toString("base64");
const METHODS = ["steps/userMessage", "steps/toolCallRequest", "steps/agentResponse"];
const allow = { decision: { decision: "allow" } };
const rewrite = { decision: { decision: "modify", reasoning: "mask", modifications: { parameter_overrides: { command: "ls /tmp/work" } } } };

function store() {
  const rows = new Map<string, unknown>();
  return {
    register: async (k: string, v: unknown) => void rows.set(k, v),
    registerIfAbsent: async (k: string, v: unknown) => (rows.has(k) ? false : (rows.set(k, v), true)),
    lookup: async (k: string) => rows.get(k),
    consume: async (k: string) => { const v = rows.get(k); rows.delete(k); return v; },
    delete: async (k: string) => void rows.delete(k),
    entries: async () => [...rows].map(([key, value]) => ({ key, value })),
    update: async (k: string, f: (c: unknown) => unknown) => { const v = f(rows.get(k)); if (v !== undefined) rows.set(k, v); },
  };
}

function load(registry: { typedHooks: unknown[] }, pluginId: string, guardianUrl: string) {
  const stores = new Map<string, ReturnType<typeof store>>();
  plugin.register({
    pluginConfig: { guardianUrl, hmacKeyId: KEY.keyId, hmacSecretEnv: "ACS_REPRO_SECRET" },
    runtime: { state: { openKeyedStore: ({ namespace }: { namespace: string }) => stores.get(namespace) ?? stores.set(namespace, store()).get(namespace)! } },
    logger: { error() {}, warn() {}, info() {} },
    on: (hookName: string, handler: unknown, opts?: { priority?: number }) => registry.typedHooks.push({ pluginId, hookName, handler, priority: opts?.priority }),
    registerAgentToolResultMiddleware() {},
  } as never);
}

it("lets the second adapter's allow undo the first adapter's modify", async () => {
  const a = startFakeGuardian({ key: KEY, methodsEvaluated: METHODS, reply: (s) => (s.method === "steps/toolCallRequest" ? rewrite : allow) });
  const b = startFakeGuardian({ key: KEY, methodsEvaluated: METHODS, reply: () => allow });
  const registry = { typedHooks: [] as unknown[] };
  load(registry, "acs-a", a.url);
  load(registry, "acs-b", b.url);
  const runner = createHookRunner(registry as never, {});
  const ctx = { runId: "run-1", sessionKey: "agent:main:signal:direct:+15550100", sessionId: "session-1", senderId: "+15550100", channel: "signal" };
  await runner.runBeforeAgentRun({ prompt: "clean up", senderId: "+15550100" }, ctx);
  const result = await runner.runBeforeToolCall({ toolName: "exec", params: { command: "rm -rf /tmp/work" } }, ctx);
  a.stop();
  b.stop();
  expect(result?.params).toEqual({ command: "rm -rf /tmp/work" }); // Guardian A asked for "ls /tmp/work"
});

}

/** OpenClaw runs higher priorities first; this plugin runs last, so its answer is the one that stands. */
const RUN_LAST = { priority: Number.MIN_SAFE_INTEGER };

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This keeps the plugin last only while nothing else registers below it, or at the same priority and later. OpenClaw sorts hooks by priority without validating the number, and it breaks ties by load order. A test plugin at -Infinity ran after this one, and its params won in 3 of 3 live runs. The comment on line 187 says this plugin runs last.

case "allow":
await markRan();
// This handler runs last, so OpenClaw uses the parameters the Guardian saw.
return confirm({ params: event.params });

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With two copies of this plugin, each one sees the original params, because OpenClaw clones the event for each before_tool_call handler. This allow returns those params, and OpenClaw keeps the last params returned, so a later copy's allow drops an earlier copy's modify. The original command ran in 3 of 3 live runs.

Returning params here also keeps a higher-priority plugin's change from running unseen by the Guardian. Returning nothing on allow would trade one problem for the other, so detecting a second copy at registration may be the cleaner fix.

return (await exchange.withoutDecision(METHODS.agentResponse, sessionIdOf(ctx.sessionKey), found)).decision.decision === "allow" ? undefined : { cancel: true };
}
const decided = await decideResponse(found.step, event.content, mediaUrlsOf(event.metadata), found.isCurrent);
return decided === STALE || decided === undefined ? { cancel: true } : { content: decided };

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the same pattern on message_sending. Every handler gets the original event, and OpenClaw keeps the last content, so this allow drops an earlier copy's rewrite. I reproduced this at the hook level only. A reply that passes reply_payload_sending first keeps the earlier copy's redaction, because that hook returns nothing on allow (line 216). A delivery that reaches only message_sending loses it.

Comment thread adapters/openclaw/runs.ts
ctx: AgentContext,
opened: { turn?: OpenedTurn & { refusedAtStart: boolean; deadlineAt: number } },
): Promise<string | undefined> {
const deadlineAt = exchange.deadlineFromNow();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each admission gets a fresh deadline, and OpenClaw's hook timeout races the handler without cancelling it. A Guardian whose decision starts a run on this gateway produced 47–49 nested runs in 40 s live, with the A2A rate limit off. At most 6 were open at once, so a concurrency cap would not trip. A breaker after repeated deadline expiries would, though I have not tested it.

Comment thread adapters/openclaw/runs.ts
return {
userContext: { user_id: senderId, authentication_method: ctx.channel },
channel: ctx.channel,
triggerType: triggerTypeOf(ctx.trigger),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A message from an A2A peer becomes user_message. A message sent from the agent's exec tool to its own gateway reached the Guardian with the same user_context as the original, and nothing linked the two. The agentTrigger section of the Hooks page says A2A delegation uses trigger_type: "a2a_inbound" to carry the peer's identity. This PR's README also sends agentTrigger for runs that do not start with a person's message.

Comment thread adapters/openclaw/README.md Outdated

A decision this host cannot carry out as sent is blocked and written to the audit log as `substituted`. This includes `modify` on a user message. It also includes `modify` on a reply with media at `message_sending`, or with a structured part at `reply_payload_sending`. The adapter also blocks `modify` on a result that is not text or images (`modify_unsupported`, §6.5). It blocks `ask` on any step except a tool call. It blocks every `defer`.

The plugin registers every hook at the lowest priority. OpenClaw therefore keeps this plugin's tool-call `params` and returned reply text. A plugin cannot detect two cases that break this rule. First, OpenClaw keeps the first approval request any plugin returns, with that plugin's `params`. Second, OpenClaw runs each plugin's tool-result middleware in turn. Do not run another plugin that returns `params` or `requireApproval` from `before_tool_call`, or registers tool-result middleware.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Number.MIN_SAFE_INTEGER is not the lowest priority OpenClaw accepts (see acs-plugin.ts:188). This rule also needs to name a second copy of this adapter. An operator who runs two Guardians will not read "another plugin" as covering it.

@Lrd0036

Lrd0036 commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator

Sorry for bombarding you with the comments. I was more just curious about this one because we're not black-boxed on the actual codebase for... what this adapter feeds into. I kept these as comments instead of requested changes just because they're optional, but could theoretically drive improvements with the 'black box' adapters.

I don't know what else to call them besides 'black box'–all I mean by that is Openclaw is OSS. Codex is not. We can hope that Claude, Cursor, and Codex have accurate documentation. lol.

but! I did test this thorougly and it might be over-engineering to actually implement these fixes. so. it's optional.

Thanks

Another plugin or a second copy of this one can run after the adapter and replace the Guardian's decision. The adapter also does not support a Guardian that uses an agent on this gateway as its judge. The agent must not reach the HMAC secret or channel tokens.

Signed-off-by: Konstantin Trunin <konstantin@trunin.com>

Claude-Session: https://claude.ai/code/session_01Rhwvb3ZmUcSATWXvWyPs2z
…claw-adapter

Signed-off-by: Konstantin Trunin <konstantin@trunin.com>
@President

Copy link
Copy Markdown
Author

@Lrd0036 huge thanks for the review! All five are addressed in 28844e9:

1 and 2. Another plugin, or a second copy of this one, can run after the adapter. We can't prevent that, so the README now warns about it. Whoever installs plugins is responsible. I didn't add a check for a second copy: it's a rare case, and IMHO it's not worth the extra code.

3. IMHO it isn't an adapter problem - it's a Guardian's. The README says this setup isn't supported, and I opened #514 to discuss it for the spec.

4. Any agent with exec can read secrets. The README now says to deny exec or run it in a sandbox, which drops the gateway's env vars.

5. The README also says to keep channel tokens away from the agent. a2a_inbound wouldn't tell us which run sent the message, so agentTrigger wouldn't help, and I kept userMessage. It stays listed under "Not met".

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

[RefImpl] OpenClaw host adapter

3 participants