@randromeda/arbiter-openclaw 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@randromeda/arbiter-openclaw` will be documented in this file.
4
+
5
+ ## [0.1.0] - 2026-04-02
6
+
7
+ - Initial native OpenClaw plugin implementation.
8
+ - `before_tool_call` enforcement with Arbiter intercept + verify.
9
+ - `after_tool_call` state recording support.
10
+ - Config schema, package metadata, and local/npm install docs.
11
+ - Unit tests for config, deny/allow paths, and state recording.
package/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # OpenClaw Native Plugin
2
+
3
+ This package provides a native OpenClaw plugin that enforces Arbiter policy decisions for protected tool calls.
4
+
5
+ Behavior:
6
+
7
+ - Intercepts protected tools in `before_tool_call`.
8
+ - Calls Arbiter `POST /v1/intercept/framework/generic`.
9
+ - Requires successful Arbiter verify via `POST /v1/execute/verify/canonical` before execution.
10
+ - Records post-call outcomes to `POST /v1/state/actions` (enabled by default).
11
+
12
+ ## Install
13
+
14
+ Published package target:
15
+
16
+ 1. `@randromeda/arbiter-openclaw`
17
+
18
+ Install from npm:
19
+
20
+ ```bash
21
+ openclaw plugins install @randromeda/arbiter-openclaw
22
+ ```
23
+
24
+ Install from local path:
25
+
26
+ ```bash
27
+ openclaw plugins install ./integrations/openclaw-plugin
28
+ ```
29
+
30
+ ## Config
31
+
32
+ Add plugin config under `plugins.entries.arbiter-openclaw.config` in your OpenClaw config:
33
+
34
+ ```json
35
+ {
36
+ "plugins": {
37
+ "entries": {
38
+ "arbiter-openclaw": {
39
+ "enabled": true,
40
+ "config": {
41
+ "arbiterUrl": "http://localhost:8080",
42
+ "tenantId": "tenant-demo",
43
+ "gatewayKey": "gw-key",
44
+ "serviceKey": "svc-key",
45
+ "protectTools": ["exec", "process", "write", "edit", "apply_patch"],
46
+ "recordState": true,
47
+ "failClosed": true
48
+ }
49
+ }
50
+ }
51
+ }
52
+ }
53
+ ```
54
+
55
+ Config options:
56
+
57
+ - `arbiterUrl`: Arbiter base URL.
58
+ - `tenantId`: Tenant ID for canonical requests and state records.
59
+ - `gatewayKey`: Optional key for intercept routes.
60
+ - `serviceKey`: Optional key for verify/state routes.
61
+ - `actorIdMode`: `agent-id` (default) or `config`.
62
+ - `actorId`: Required only when `actorIdMode=config`.
63
+ - `protectTools`: Tool names guarded by Arbiter.
64
+ - `recordState`: Record outcomes to `/v1/state/actions` (default `true`).
65
+ - `failClosed`: Block on Arbiter errors (default `true`).
66
+ - `timeoutMs`: Per-request timeout (default `5000`).
67
+
68
+ ## Stock OpenClaw Tool Mapping
69
+
70
+ The default protected tools are:
71
+
72
+ - `exec`
73
+ - `process`
74
+ - `write`
75
+ - `edit`
76
+ - `apply_patch`
77
+
78
+ Use Arbiter policy to allow or deny these tools. The included Arbiter filesystem policy denies destructive delete commands and `apply_patch` file-deletion directives.
79
+
80
+ ## Development
81
+
82
+ Run plugin tests:
83
+
84
+ ```bash
85
+ cd integrations/openclaw-plugin
86
+ npm test
87
+ ```
88
+
89
+ Verify package metadata:
90
+
91
+ ```bash
92
+ cd integrations/openclaw-plugin
93
+ npm run pack:check
94
+ ```
95
+
96
+ ## CI Publish
97
+
98
+ GitHub Actions can publish this package to npm via `.github/workflows/openclaw-plugin-publish.yml`.
99
+
100
+ Requirements:
101
+
102
+ - Repository secret `NPM_TOKEN` with publish access to `@randromeda/arbiter-openclaw`.
103
+
104
+ Publish options:
105
+
106
+ - Run `npm run release:tag` from `integrations/openclaw-plugin` to create and push `openclaw-plugin-v<version>` automatically.
107
+ - For validation without changing git state, run `npm run release:tag -- --dry-run`.
108
+ - Or push tag `openclaw-plugin-v<version>` manually (must match `package.json` version), for example `openclaw-plugin-v0.1.0`.
109
+ - Or run the `openclaw-plugin-publish` workflow manually from the Actions UI.
package/SEMVER.md ADDED
@@ -0,0 +1,27 @@
1
+ # Versioning Policy
2
+
3
+ `@randromeda/arbiter-openclaw` uses Semantic Versioning (`MAJOR.MINOR.PATCH`).
4
+
5
+ ## Rules
6
+
7
+ - `MAJOR`: breaking config contract, hook behavior, or install/runtime expectations.
8
+ - `MINOR`: backward-compatible features, new guardrail options, new supported tools.
9
+ - `PATCH`: backward-compatible bug fixes, policy mapping fixes, docs corrections.
10
+
11
+ ## Stability Notes
12
+
13
+ - Current status is pre-1.0 (`0.x`), so minor versions may include breaking changes.
14
+ - After `1.0.0`, breaking changes move to major version bumps.
15
+
16
+ ## Release Checklist
17
+
18
+ 1. Update package version in `package.json`.
19
+ 2. Add changelog entry in `CHANGELOG.md`.
20
+ 3. Run tests:
21
+ - `npm test`
22
+ 4. Validate package metadata:
23
+ - `npm run pack:check`
24
+ 5. Publish:
25
+ - run `npm run release:tag` (or `npm run release:tag -- --dry-run`) from `integrations/openclaw-plugin`.
26
+ - alternatively, push git tag `openclaw-plugin-v<version>` manually to trigger CI publish.
27
+ - workflow `openclaw-plugin-publish` can also be run manually.
package/index.js ADDED
@@ -0,0 +1,19 @@
1
+ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
2
+ import { createArbiterGuardrail } from "./src/guardrail.js";
3
+
4
+ export default definePluginEntry({
5
+ id: "arbiter-openclaw",
6
+ name: "Arbiter Guardrails",
7
+ description: "Enforce Arbiter policy decisions before OpenClaw tool execution.",
8
+ register(api) {
9
+ const guardrail = createArbiterGuardrail({
10
+ pluginConfig: api.pluginConfig,
11
+ logger: api.logger,
12
+ fetchImpl: globalThis.fetch,
13
+ env: process.env
14
+ });
15
+
16
+ api.on("before_tool_call", guardrail.beforeToolCall);
17
+ api.on("after_tool_call", guardrail.afterToolCall);
18
+ }
19
+ });
@@ -0,0 +1,110 @@
1
+ {
2
+ "id": "arbiter-openclaw",
3
+ "name": "Arbiter Guardrails",
4
+ "description": "Enforce Arbiter policy decisions for OpenClaw tool calls before execution.",
5
+ "configSchema": {
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "arbiterUrl": {
10
+ "type": "string",
11
+ "format": "uri",
12
+ "pattern": "^[Hh][Tt][Tt][Pp][Ss]?://"
13
+ },
14
+ "tenantId": {
15
+ "type": "string",
16
+ "minLength": 1
17
+ },
18
+ "gatewayKey": {
19
+ "type": "string"
20
+ },
21
+ "serviceKey": {
22
+ "type": "string"
23
+ },
24
+ "actorIdMode": {
25
+ "type": "string",
26
+ "enum": [
27
+ "agent-id",
28
+ "config"
29
+ ],
30
+ "default": "agent-id"
31
+ },
32
+ "actorId": {
33
+ "type": "string"
34
+ },
35
+ "protectTools": {
36
+ "type": "array",
37
+ "items": {
38
+ "type": "string",
39
+ "minLength": 1
40
+ },
41
+ "default": [
42
+ "exec",
43
+ "process",
44
+ "write",
45
+ "edit",
46
+ "apply_patch"
47
+ ]
48
+ },
49
+ "recordState": {
50
+ "type": "boolean",
51
+ "default": true
52
+ },
53
+ "failClosed": {
54
+ "type": "boolean",
55
+ "default": true
56
+ },
57
+ "timeoutMs": {
58
+ "type": "integer",
59
+ "minimum": 250,
60
+ "maximum": 60000,
61
+ "default": 5000
62
+ }
63
+ }
64
+ },
65
+ "uiHints": {
66
+ "arbiterUrl": {
67
+ "label": "Arbiter Base URL",
68
+ "help": "Base URL for the Arbiter interceptor, for example http://localhost:8080."
69
+ },
70
+ "tenantId": {
71
+ "label": "Tenant ID",
72
+ "help": "Tenant identifier included in canonical requests and state records."
73
+ },
74
+ "gatewayKey": {
75
+ "label": "Gateway Shared Key",
76
+ "help": "Optional key for /v1/intercept routes when Arbiter gateway auth is enabled.",
77
+ "sensitive": true
78
+ },
79
+ "serviceKey": {
80
+ "label": "Service Shared Key",
81
+ "help": "Optional key for /v1/execute/verify and /v1/state/actions routes.",
82
+ "sensitive": true
83
+ },
84
+ "actorIdMode": {
85
+ "label": "Actor ID Source",
86
+ "help": "Use OpenClaw agent ID by default or force a fixed actor ID from plugin config."
87
+ },
88
+ "actorId": {
89
+ "label": "Fixed Actor ID",
90
+ "help": "Used only when actorIdMode is config."
91
+ },
92
+ "protectTools": {
93
+ "label": "Protected Tool Names",
94
+ "help": "Only tools in this list are intercepted by Arbiter."
95
+ },
96
+ "recordState": {
97
+ "label": "Record Action State",
98
+ "help": "When enabled, sends /v1/state/actions after guarded tool calls."
99
+ },
100
+ "failClosed": {
101
+ "label": "Fail Closed",
102
+ "help": "Block protected tool calls when Arbiter is unavailable or responses are invalid."
103
+ },
104
+ "timeoutMs": {
105
+ "label": "HTTP Timeout (ms)",
106
+ "help": "Per-request timeout for Arbiter intercept, verify, and state calls.",
107
+ "advanced": true
108
+ }
109
+ }
110
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@randromeda/arbiter-openclaw",
3
+ "version": "0.1.0",
4
+ "description": "OpenClaw native plugin that enforces Arbiter guardrails before tool execution.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=20"
9
+ },
10
+ "files": [
11
+ "index.js",
12
+ "openclaw.plugin.json",
13
+ "src",
14
+ "scripts",
15
+ "README.md",
16
+ "CHANGELOG.md",
17
+ "SEMVER.md"
18
+ ],
19
+ "exports": {
20
+ ".": "./index.js"
21
+ },
22
+ "scripts": {
23
+ "test": "node --test ./test/*.test.js",
24
+ "pack:check": "npm pack --dry-run",
25
+ "release:tag": "bash ./scripts/release-tag.sh"
26
+ },
27
+ "openclaw": {
28
+ "extensions": [
29
+ "./index.js"
30
+ ]
31
+ }
32
+ }
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ DRY_RUN="${1:-}"
5
+
6
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
+ REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
8
+ PACKAGE_JSON="${REPO_ROOT}/integrations/openclaw-plugin/package.json"
9
+
10
+ if ! command -v node >/dev/null 2>&1; then
11
+ echo "node is required but not found in PATH."
12
+ exit 1
13
+ fi
14
+
15
+ VERSION="$(node -e "console.log(require(process.argv[1]).version)" "$PACKAGE_JSON")"
16
+ TAG="openclaw-plugin-v${VERSION}"
17
+
18
+ if [[ -z "${VERSION}" ]]; then
19
+ echo "failed to resolve version from ${PACKAGE_JSON}."
20
+ exit 1
21
+ fi
22
+
23
+ if [[ "${DRY_RUN}" == "--dry-run" ]]; then
24
+ if ! git -C "${REPO_ROOT}" diff --quiet || ! git -C "${REPO_ROOT}" diff --cached --quiet; then
25
+ echo "dry run: working tree is not clean; a real tag run would fail."
26
+ fi
27
+ echo "dry run: would run 'git tag -a ${TAG} -m \"OpenClaw plugin ${VERSION}\"'"
28
+ echo "dry run: would run 'git push origin ${TAG}'"
29
+ exit 0
30
+ fi
31
+
32
+ if ! git -C "${REPO_ROOT}" diff --quiet || ! git -C "${REPO_ROOT}" diff --cached --quiet; then
33
+ echo "working tree is not clean. commit or stash changes before tagging."
34
+ exit 1
35
+ fi
36
+
37
+ if git -C "${REPO_ROOT}" rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
38
+ echo "tag ${TAG} already exists."
39
+ exit 1
40
+ fi
41
+
42
+ if [[ -n "${DRY_RUN}" ]]; then
43
+ echo "unknown argument: ${DRY_RUN}"
44
+ echo "usage: npm run release:tag [-- --dry-run]"
45
+ exit 1
46
+ fi
47
+
48
+ git -C "${REPO_ROOT}" tag -a "${TAG}" -m "OpenClaw plugin ${VERSION}"
49
+ git -C "${REPO_ROOT}" push origin "${TAG}"
50
+ echo "created and pushed ${TAG}"
@@ -0,0 +1,56 @@
1
+ import { resolveSessionMetadata } from "./config.js";
2
+
3
+ const SCHEMA_VERSION = "v1alpha1";
4
+
5
+ function randomSuffix() {
6
+ if (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function") {
7
+ return globalThis.crypto.randomUUID();
8
+ }
9
+ return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
10
+ }
11
+
12
+ export function createRequestId(event) {
13
+ const runId = typeof event.runId === "string" ? event.runId.trim() : "";
14
+ const toolCallId = typeof event.toolCallId === "string" ? event.toolCallId.trim() : "";
15
+
16
+ if (runId && toolCallId) {
17
+ return `${runId}:${toolCallId}`;
18
+ }
19
+ if (toolCallId) {
20
+ return `tool:${toolCallId}`;
21
+ }
22
+ if (runId) {
23
+ return `${runId}:${randomSuffix()}`;
24
+ }
25
+ return `arbiter-${randomSuffix()}`;
26
+ }
27
+
28
+ export function buildCanonicalRequest({ config, event, ctx, actorId, requestId }) {
29
+ const session = resolveSessionMetadata(ctx);
30
+ const metadata = {
31
+ request_id: requestId,
32
+ tenant_id: config.tenantId,
33
+ provider: "framework"
34
+ };
35
+
36
+ if (session.sessionId) {
37
+ metadata.session_id = session.sessionId;
38
+ } else if (session.sessionKey) {
39
+ metadata.session_id = session.sessionKey;
40
+ }
41
+ if (typeof event.runId === "string" && event.runId.trim()) {
42
+ metadata.trace_id = event.runId.trim();
43
+ }
44
+
45
+ return {
46
+ schema_version: SCHEMA_VERSION,
47
+ metadata,
48
+ agent_context: {
49
+ actor: {
50
+ id: actorId
51
+ }
52
+ },
53
+ tool_name: event.toolName,
54
+ parameters: event.params ?? {}
55
+ };
56
+ }
package/src/config.js ADDED
@@ -0,0 +1,95 @@
1
+ export const DEFAULT_PROTECT_TOOLS = ["exec", "process", "write", "edit", "apply_patch"];
2
+ export const DEFAULT_TIMEOUT_MS = 5000;
3
+
4
+ function asRecord(value) {
5
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
6
+ }
7
+
8
+ function asString(value) {
9
+ return typeof value === "string" ? value.trim() : "";
10
+ }
11
+
12
+ function asBool(value, fallback) {
13
+ return typeof value === "boolean" ? value : fallback;
14
+ }
15
+
16
+ function asTimeoutMs(value) {
17
+ if (typeof value !== "number" || !Number.isFinite(value)) {
18
+ return DEFAULT_TIMEOUT_MS;
19
+ }
20
+ if (value < 250) {
21
+ return 250;
22
+ }
23
+ if (value > 60000) {
24
+ return 60000;
25
+ }
26
+ return Math.round(value);
27
+ }
28
+
29
+ function normalizeToolList(value) {
30
+ if (!Array.isArray(value)) {
31
+ return DEFAULT_PROTECT_TOOLS.slice();
32
+ }
33
+ const tools = value
34
+ .map((entry) => asString(entry))
35
+ .filter(Boolean);
36
+ return tools.length > 0 ? Array.from(new Set(tools)) : DEFAULT_PROTECT_TOOLS.slice();
37
+ }
38
+
39
+ export function resolvePluginConfig(pluginConfig, env = process.env) {
40
+ const cfg = asRecord(pluginConfig);
41
+ const runtimeEnv = asRecord(env);
42
+
43
+ const arbiterUrl = asString(cfg.arbiterUrl) || asString(runtimeEnv.ARBITER_URL);
44
+ const tenantId = asString(cfg.tenantId) || asString(runtimeEnv.ARBITER_TENANT_ID);
45
+ const gatewayKey = asString(cfg.gatewayKey) || asString(runtimeEnv.ARBITER_GATEWAY_SHARED_KEY);
46
+ const serviceKey = asString(cfg.serviceKey) || asString(runtimeEnv.ARBITER_SERVICE_SHARED_KEY);
47
+ const actorId = asString(cfg.actorId) || asString(runtimeEnv.ARBITER_ACTOR_ID);
48
+
49
+ const actorIdMode = asString(cfg.actorIdMode) === "config" ? "config" : "agent-id";
50
+ const protectTools = normalizeToolList(cfg.protectTools);
51
+ const recordState = asBool(cfg.recordState, true);
52
+ const failClosed = asBool(cfg.failClosed, true);
53
+ const timeoutMs = asTimeoutMs(cfg.timeoutMs);
54
+
55
+ const missing = [];
56
+ if (!arbiterUrl) {
57
+ missing.push("arbiterUrl");
58
+ }
59
+ if (!tenantId) {
60
+ missing.push("tenantId");
61
+ }
62
+ if (actorIdMode === "config" && !actorId) {
63
+ missing.push("actorId");
64
+ }
65
+
66
+ return {
67
+ arbiterUrl: arbiterUrl.replace(/\/$/, ""),
68
+ tenantId,
69
+ gatewayKey,
70
+ serviceKey,
71
+ actorIdMode,
72
+ actorId,
73
+ protectTools,
74
+ recordState,
75
+ failClosed,
76
+ timeoutMs,
77
+ missing
78
+ };
79
+ }
80
+
81
+ export function resolveActorId(config, ctx) {
82
+ const hookCtx = asRecord(ctx);
83
+ if (config.actorIdMode === "config") {
84
+ return config.actorId;
85
+ }
86
+ return asString(hookCtx.agentId) || config.actorId || "openclaw-agent";
87
+ }
88
+
89
+ export function resolveSessionMetadata(ctx) {
90
+ const hookCtx = asRecord(ctx);
91
+ return {
92
+ sessionId: asString(hookCtx.sessionId),
93
+ sessionKey: asString(hookCtx.sessionKey)
94
+ };
95
+ }
@@ -0,0 +1,187 @@
1
+ import { buildCanonicalRequest, createRequestId } from "./canonical.js";
2
+ import { resolveActorId, resolvePluginConfig } from "./config.js";
3
+ import { postJSON } from "./http.js";
4
+
5
+ function isProtectedTool(toolSet, toolName) {
6
+ return typeof toolName === "string" && toolSet.has(toolName);
7
+ }
8
+
9
+ function decisionReason(body) {
10
+ if (!body || typeof body !== "object") {
11
+ return "";
12
+ }
13
+ if (typeof body.error === "string" && body.error.trim()) {
14
+ return body.error.trim();
15
+ }
16
+ const decision = body.decision;
17
+ if (decision && typeof decision === "object" && typeof decision.reason === "string") {
18
+ return decision.reason.trim();
19
+ }
20
+ return "";
21
+ }
22
+
23
+ function block(reason) {
24
+ return {
25
+ block: true,
26
+ blockReason: reason
27
+ };
28
+ }
29
+
30
+ function configError(config) {
31
+ if (!config.missing.length) {
32
+ return "";
33
+ }
34
+ return `arbiter plugin misconfigured: missing ${config.missing.join(", ")}`;
35
+ }
36
+
37
+ function serviceHeaders(config) {
38
+ return config.serviceKey ? { "X-Arbiter-Service-Key": config.serviceKey } : {};
39
+ }
40
+
41
+ function gatewayHeaders(config) {
42
+ return config.gatewayKey ? { "X-Arbiter-Gateway-Key": config.gatewayKey } : {};
43
+ }
44
+
45
+ export function createArbiterGuardrail({
46
+ pluginConfig,
47
+ logger,
48
+ fetchImpl = globalThis.fetch,
49
+ env = process.env
50
+ }) {
51
+ const config = resolvePluginConfig(pluginConfig, env);
52
+ const protectedTools = new Set(config.protectTools);
53
+
54
+ async function beforeToolCall(event, ctx) {
55
+ if (!isProtectedTool(protectedTools, event?.toolName)) {
56
+ return;
57
+ }
58
+
59
+ const cfgError = configError(config);
60
+ if (cfgError) {
61
+ if (config.failClosed) {
62
+ return block(cfgError);
63
+ }
64
+ logger?.warn?.(`${cfgError}; allowing because failClosed=false`);
65
+ return;
66
+ }
67
+
68
+ const actorId = resolveActorId(config, ctx);
69
+ const requestId = createRequestId(event);
70
+ const canonical = buildCanonicalRequest({
71
+ config,
72
+ event,
73
+ ctx,
74
+ actorId,
75
+ requestId
76
+ });
77
+
78
+ let intercept;
79
+ try {
80
+ intercept = await postJSON({
81
+ fetchImpl,
82
+ baseUrl: config.arbiterUrl,
83
+ path: "/v1/intercept/framework/generic",
84
+ headers: gatewayHeaders(config),
85
+ payload: canonical,
86
+ timeoutMs: config.timeoutMs
87
+ });
88
+ } catch (err) {
89
+ if (config.failClosed) {
90
+ return block(`arbiter intercept failed: ${String(err)}`);
91
+ }
92
+ logger?.warn?.(`arbiter intercept failed; allowing because failClosed=false: ${String(err)}`);
93
+ return;
94
+ }
95
+
96
+ const interceptReason = decisionReason(intercept.body);
97
+ if (intercept.status !== 200) {
98
+ if (intercept.status === 403) {
99
+ return block(interceptReason || "blocked by arbiter policy");
100
+ }
101
+ if (config.failClosed) {
102
+ return block(`arbiter intercept failed (${intercept.status})${interceptReason ? `: ${interceptReason}` : ""}`);
103
+ }
104
+ logger?.warn?.(`arbiter intercept status=${intercept.status}; allowing because failClosed=false`);
105
+ return;
106
+ }
107
+
108
+ const token = intercept.body?.token;
109
+ if (typeof token !== "string" || !token.trim()) {
110
+ if (config.failClosed) {
111
+ return block("arbiter intercept response missing token");
112
+ }
113
+ logger?.warn?.("arbiter intercept response missing token; allowing because failClosed=false");
114
+ return;
115
+ }
116
+
117
+ let verify;
118
+ try {
119
+ verify = await postJSON({
120
+ fetchImpl,
121
+ baseUrl: config.arbiterUrl,
122
+ path: "/v1/execute/verify/canonical",
123
+ headers: serviceHeaders(config),
124
+ payload: {
125
+ token,
126
+ request: canonical
127
+ },
128
+ timeoutMs: config.timeoutMs
129
+ });
130
+ } catch (err) {
131
+ if (config.failClosed) {
132
+ return block(`arbiter verify failed: ${String(err)}`);
133
+ }
134
+ logger?.warn?.(`arbiter verify failed; allowing because failClosed=false: ${String(err)}`);
135
+ return;
136
+ }
137
+
138
+ if (verify.status !== 200) {
139
+ const verifyReason = decisionReason(verify.body);
140
+ return block(`arbiter verify denied${verifyReason ? `: ${verifyReason}` : ""}`);
141
+ }
142
+ }
143
+
144
+ async function afterToolCall(event, ctx) {
145
+ if (!config.recordState || !isProtectedTool(protectedTools, event?.toolName)) {
146
+ return;
147
+ }
148
+ if (!config.tenantId) {
149
+ return;
150
+ }
151
+
152
+ const actorId = resolveActorId(config, ctx);
153
+ const outcome = event?.error ? "error" : "allowed";
154
+ const payload = {
155
+ tenant_id: config.tenantId,
156
+ actor_id: actorId,
157
+ tool_name: event.toolName,
158
+ outcome
159
+ };
160
+ if (ctx?.sessionId && typeof ctx.sessionId === "string") {
161
+ payload.session_id = ctx.sessionId;
162
+ } else if (ctx?.sessionKey && typeof ctx.sessionKey === "string") {
163
+ payload.session_id = ctx.sessionKey;
164
+ }
165
+
166
+ try {
167
+ const response = await postJSON({
168
+ fetchImpl,
169
+ baseUrl: config.arbiterUrl,
170
+ path: "/v1/state/actions",
171
+ headers: serviceHeaders(config),
172
+ payload,
173
+ timeoutMs: config.timeoutMs
174
+ });
175
+ if (response.status !== 202 && response.status !== 200) {
176
+ logger?.warn?.(`arbiter state record returned status=${response.status}`);
177
+ }
178
+ } catch (err) {
179
+ logger?.warn?.(`arbiter state record failed: ${String(err)}`);
180
+ }
181
+ }
182
+
183
+ return {
184
+ beforeToolCall,
185
+ afterToolCall
186
+ };
187
+ }
package/src/http.js ADDED
@@ -0,0 +1,43 @@
1
+ function joinUrl(baseUrl, path) {
2
+ const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
3
+ const normalized = path.startsWith("/") ? path.slice(1) : path;
4
+ return new URL(normalized, base).toString();
5
+ }
6
+
7
+ async function parseJSONResponse(resp) {
8
+ const text = await resp.text();
9
+ if (!text) {
10
+ return null;
11
+ }
12
+ try {
13
+ return JSON.parse(text);
14
+ } catch {
15
+ return { error: text };
16
+ }
17
+ }
18
+
19
+ export async function postJSON({ fetchImpl, baseUrl, path, headers, payload, timeoutMs }) {
20
+ const controller = new AbortController();
21
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
22
+ try {
23
+ const response = await fetchImpl(joinUrl(baseUrl, path), {
24
+ method: "POST",
25
+ headers: {
26
+ Accept: "application/json",
27
+ "Content-Type": "application/json",
28
+ ...headers
29
+ },
30
+ body: JSON.stringify(payload),
31
+ signal: controller.signal
32
+ });
33
+
34
+ const body = await parseJSONResponse(response);
35
+ return {
36
+ status: response.status,
37
+ ok: response.ok,
38
+ body
39
+ };
40
+ } finally {
41
+ clearTimeout(timeout);
42
+ }
43
+ }