Skip to content

feat(adapters): OpenClaw before_tool_call plugin with fail-closed subprocess bridge - #138

Merged
fu351 merged 2 commits into
mainfrom
feat/adapters/openclaw-hook-pack
Jul 20, 2026
Merged

feat(adapters): OpenClaw before_tool_call plugin with fail-closed subprocess bridge#138
fu351 merged 2 commits into
mainfrom
feat/adapters/openclaw-hook-pack

Conversation

@fu351

@fu351 fu351 commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Slice

  • Repo: doberman-core
  • Feature / Slice: ADR 0038 — OpenClaw adapter bridge (no numbered F* slice; cross-cutting adapter feature tracked via ADR)
  • Plan reference: ADR 0038 (doberman-vault/Decisions/0038-openclaw-adapter-bridge.md), doberman_implementation_plan.md

What this PR does

Adds an OpenClaw plugin (adapters/openclaw/) that registers a before_tool_call hook and gates every OpenClaw tool call through Doberman's existing PASS/AUTH/BLOCK decision engine, via a fresh doberman hook openclaw subprocess per call (mirrors the existing Claude Code hook adapter's architecture).

  • src/doberman/hosthooks/openclaw.py: parses the before_tool_call event JSON on stdin, normalizes built-in tools to SecurityObject, and reuses claude_code.py's exact decision pipeline (decide, apply_taint_floor, resolve_enforcement_sync, acted_verdict). Always emits exactly one JSON verdict on stdout; never raises.
  • src/doberman/cli/main.py: new doberman hook openclaw command.
  • adapters/openclaw/openclaw.plugin.json + package.json + index.js: the OpenClaw plugin manifest, package.json's openclaw.extensions entry-point wiring, and a plain ESM bridge (no build step) that spawns the Python hook with a 10s timeout (under OpenClaw's own 15s hook timeout budget) and fails closed on any spawn error, timeout, non-zero exit, or unparseable stdout.
  • adapters/openclaw/README.md: install steps, verdict mapping, and a mandatory "verify it's live" canary check — motivated by OpenClaw #5513 (plugin hooks can silently never fire) and #20914 (a plugin that fails to load leaves OpenClaw running with zero interception, logged but otherwise silent).
  • Root README.md: new "Alternative: enforce via OpenClaw" section + roadmap bullet.

Per ADR 0038 (amended after a build-time doc-verification check caught the original design's mistake): packaged as a real OpenClaw plugin, not a HOOK.md hook-pack — hook-packs can only reach lifecycle/command events, never before_tool_call. That would have shipped a silent fail-OPEN adapter.

Verdict mapping: PASS -> allow (no-op); BLOCK -> {block: true, blockReason}; AUTH -> OpenClaw's own native requireApproval (allowedDecisions: ["allow-once", "deny"] only -- no "allow-always" in this slice), not Doberman's own GUI/TTY challenge.

Tests added (run in CI)

  • tests/unit/test_hosthook_openclaw.py (40 tests): event parsing/normalization for built-in tools, fail-closed behavior on malformed/missing/unknown fields, redaction (no secret ever appears in a verdict), the "exactly one JSON document on stdout" contract, PASS/AUTH/BLOCK verdict shaping, and monitor-mode history recording parity with the Claude Code hook.

Public-release safety (doberman-core only)

  • Contains nothing from the "not allowed" list: no enterprise/hosted code, no proprietary detection, no customer data, no secrets, no commercial-license code
  • Core still builds/tests/runs with NO enterprise package installed

Security checklist

  • Fails closed on error / uncertainty
  • No secret, full file, or unredacted prompt logged or committed
  • Any guardrail/learning change is raise-only (no silent loosening) -- N/A, no guardrail/learning logic changed, this is a new host-adapter surface reusing the existing decision pipeline unmodified
  • Every BLOCK/AUTH carries reason codes + a human explanation
  • doberman-core does not import doberman_enterprise

Edge cases covered / Deviations from plan / Risks introduced

Edge cases: malformed/non-JSON stdin, missing/non-string tool_name, unrecognized tool names (normalized generically, never silently passed), missing required per-tool fields (e.g. exec without command), missing derived_paths for path-bearing actions, subprocess spawn failure, subprocess timeout, non-zero exit, unparseable stdout -- all fail closed.

Deviations from the original design sketch:

  • Denylist-abstain model: only an explicit _ABSTAIN_TOOLS set abstains from evaluation; everything else -- including tool names Doberman doesn't recognize -- is evaluated for real rather than allowlisted through. Nothing unrecognized silently passes.
  • AUTH is delegated to OpenClaw's own requireApproval/onResolution flow instead of Doberman's synchronous challenge, since OpenClaw's approval resolves asynchronously outside this subprocess's lifetime.
  • AUTH-outcome history recording is out of scope for this slice (documented in the adapter README under "Known limitations") -- the subprocess exits before an async approval resolves, so only BLOCK verdicts are recorded to history from this hook today.
  • web_fetch's params.url key is best-effort/unconfirmed against OpenClaw's actual event shape (flagged both in code comments and the adapter README); every other field mapping (toolName, params, toolKind, derivedPaths, before_tool_call's BeforeToolCallResult shape) was verified directly against current docs.openclaw.ai.

Risks: the OpenClaw plugin/event contract was verified against live documentation this session (not assumed from training data), but has not been tested against a real running OpenClaw instance (no local OpenClaw install available in this environment) -- the adapter README's canary check exists specifically so an operator confirms real interception before trusting it.

fu351 added 2 commits July 19, 2026 17:51
…process bridge

Adds an OpenClaw plugin (adapters/openclaw/) that registers a
before_tool_call hook and gates every tool call through Doberman's
existing PASS/AUTH/BLOCK decision engine via a fresh `doberman hook
openclaw` subprocess per call. Any spawn failure, timeout (10s, under
the platform's own 15s hook timeout), non-zero exit, or unparseable
stdout fails closed (block). AUTH verdicts are delegated to OpenClaw's
native requireApproval mechanism instead of Doberman's own challenge.

Per ADR 0038 (amended): packaged as a real plugin (manifest +
api.on("before_tool_call", ...)), not a HOOK.md hook-pack, since
hook-packs cannot reach before_tool_call at all. The adapter README
includes a mandatory "verify it's live" canary check, motivated by
OpenClaw #5513 (plugin hooks silently never firing) and #20914 (a
plugin that fails to load leaves OpenClaw running with zero
interception).

- src/doberman/hosthooks/openclaw.py: parse the before_tool_call event,
  normalize built-in tools to SecurityObject, reuse claude_code.py's
  decision pipeline (decide, apply_taint_floor, resolve_enforcement_sync,
  acted_verdict), always emit exactly one JSON verdict, never raise.
- src/doberman/cli/main.py: `doberman hook openclaw` command.
- adapters/openclaw/{openclaw.plugin.json,package.json,index.js}:
  the plugin manifest + ESM entry (no build step) + subprocess bridge.
- adapters/openclaw/README.md: install steps, verdict mapping, the
  mandatory canary check, and known limitations.
- tests/unit/test_hosthook_openclaw.py: 40 tests covering redaction,
  fail-closed paths, and the single-JSON-document contract.
Address PR #138 cross-review: "read" sat in _ABSTAIN_TOOLS, so an OpenClaw
agent could read .env/keys/control-plane files unchallenged, contradicting
this module's own gate-by-default rationale. "read" now normalizes to
read_file (parity with claude_code.py) and is required to carry params.path
(confirmed via openclaw/openclaw#2596 and #16717), so its target is gated by
ProtectedPathRule like any other file-touching action -- a .env read is now
blocked, a benign read still allows, and a missing/empty path fails closed.
Read *content* remains unscanned in this slice (documented follow-up); only
the target-path gate changes. Also fixes a docstring typo ({}} -> {}).
@fu351
fu351 merged commit 10aec86 into main Jul 20, 2026
8 of 9 checks passed
@fu351
fu351 deleted the feat/adapters/openclaw-hook-pack branch July 20, 2026 02:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant