feat(adapters): OpenClaw before_tool_call plugin with fail-closed subprocess bridge - #138
Merged
Conversation
…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 ({}} -> {}).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Slice
What this PR does
Adds an OpenClaw plugin (
adapters/openclaw/) that registers abefore_tool_callhook and gates every OpenClaw tool call through Doberman's existing PASS/AUTH/BLOCK decision engine, via a freshdoberman hook openclawsubprocess per call (mirrors the existing Claude Code hook adapter's architecture).src/doberman/hosthooks/openclaw.py: parses thebefore_tool_callevent JSON on stdin, normalizes built-in tools toSecurityObject, and reusesclaude_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: newdoberman hook openclawcommand.adapters/openclaw/openclaw.plugin.json+package.json+index.js: the OpenClaw plugin manifest,package.json'sopenclaw.extensionsentry-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).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.mdhook-pack — hook-packs can only reach lifecycle/command events, neverbefore_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 nativerequireApproval(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)
Security checklist
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.execwithoutcommand), missingderived_pathsfor path-bearing actions, subprocess spawn failure, subprocess timeout, non-zero exit, unparseable stdout -- all fail closed.Deviations from the original design sketch:
_ABSTAIN_TOOLSset abstains from evaluation; everything else -- including tool names Doberman doesn't recognize -- is evaluated for real rather than allowlisted through. Nothing unrecognized silently passes.requireApproval/onResolutionflow instead of Doberman's synchronous challenge, since OpenClaw's approval resolves asynchronously outside this subprocess's lifetime.web_fetch'sparams.urlkey 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'sBeforeToolCallResultshape) 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.