@aporthq/aport-agent-guardrails 1.0.21 → 1.0.23
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/README.md +8 -3
- package/bin/openclaw +82 -31
- package/docs/PROVIDER.md +6 -6
- package/docs/QUICKSTART_OPENCLAW_PLUGIN.md +42 -408
- package/docs/RELEASE.md +3 -2
- package/docs/TOOL_POLICY_MAPPING.md +2 -2
- package/docs/frameworks/openclaw.md +137 -38
- package/extensions/openclaw-aport/CHANGELOG.md +14 -1
- package/extensions/openclaw-aport/MIGRATION.md +22 -375
- package/extensions/openclaw-aport/README.md +88 -350
- package/extensions/openclaw-aport/api-client.js +30 -0
- package/extensions/openclaw-aport/audit.js +32 -0
- package/extensions/openclaw-aport/decision.js +21 -0
- package/extensions/openclaw-aport/index.js +169 -592
- package/extensions/openclaw-aport/local-evaluator.js +303 -0
- package/extensions/openclaw-aport/openclaw.plugin.json +5 -5
- package/extensions/openclaw-aport/package-lock.json +2 -2
- package/extensions/openclaw-aport/package.json +27 -6
- package/extensions/openclaw-aport/tool-mapping.js +276 -0
- package/package.json +1 -1
- package/extensions/openclaw-aport/index.ts +0 -547
- package/extensions/openclaw-aport/test.js +0 -356
|
@@ -2,633 +2,210 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* APort OpenClaw Plugin
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* Returns { block?, blockReason?, params?, reasons?, reasonSummary? }. On allow, reasons from APort are propagated for UX.
|
|
8
|
-
*
|
|
9
|
-
* Installation:
|
|
10
|
-
* openclaw plugins install /path/to/aport-agent-guardrails/extensions/openclaw-aport
|
|
11
|
-
*
|
|
12
|
-
* Configuration (in config.yaml):
|
|
13
|
-
* plugins:
|
|
14
|
-
* entries:
|
|
15
|
-
* openclaw-aport:
|
|
16
|
-
* enabled: true
|
|
17
|
-
* config:
|
|
18
|
-
* mode: local # "local" | "api"
|
|
19
|
-
* passportFile: ~/.openclaw/aport/passport.json # Omit when using agentId (hosted)
|
|
20
|
-
* agentId: ap_... # Optional: hosted passport from aport.io (API fetches passport)
|
|
21
|
-
* guardrailScript: ~/.openclaw/.skills/aport-guardrail-bash.sh
|
|
22
|
-
* apiUrl: https://api.aport.io # For API mode
|
|
23
|
-
* # apiKey optional: set APORT_API_KEY env var if your API requires it
|
|
24
|
-
* failClosed: true # Block on error
|
|
25
|
-
*
|
|
26
|
-
* Decisions (local mode): Written to <config_dir>/decisions/<timestamp>-<id>.json and left for
|
|
27
|
-
* audit. The guardrail script also appends a one-line summary to <config_dir>/audit.log (when
|
|
28
|
-
* passport is in config/aport/, audit lives in config/aport/audit.log). Decisions
|
|
29
|
-
* follow OAP v1.0 schema (see agent-passport spec/oap/decision-schema.json). Local mode uses
|
|
30
|
-
* unsigned/local-unsigned; API mode can return signed decisions (chained audit in agent-passport).
|
|
5
|
+
* Deterministic pre-action authorization via before_tool_call.
|
|
6
|
+
* Uses a built-in JS local evaluator for offline mode and direct API calls for hosted mode.
|
|
31
7
|
*/
|
|
32
8
|
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
config.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* One-line summary for ALLOW/BLOCKED logs — tool + context hint. No I/O, no heavy work.
|
|
70
|
-
* Keeps logs scannable and screenshot-friendly (e.g. "system.command.execute - mkdir test").
|
|
71
|
-
*/
|
|
72
|
-
function decisionLogSummary(effectiveToolName, policyName, context) {
|
|
73
|
-
if (policyName === "system.command.execute.v1" && context?.command) {
|
|
74
|
-
const cmd = String(context.command).replace(/\s+/g, " ").trim();
|
|
75
|
-
return cmd.length > 52 ? cmd.slice(0, 52) + "…" : cmd;
|
|
76
|
-
}
|
|
77
|
-
if (policyName === "messaging.message.send.v1") {
|
|
78
|
-
const to = context?.recipient ?? context?.to ?? "";
|
|
79
|
-
return to ? `send → ${String(to).slice(0, 32)}` : "send";
|
|
80
|
-
}
|
|
81
|
-
if (policyName?.startsWith("code.repository.")) return "repo";
|
|
82
|
-
if (policyName?.startsWith("mcp.")) return "mcp tool";
|
|
83
|
-
return policyName?.replace(/\.v\d+$/, "") ?? effectiveToolName;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Format decision.reasons (OAP code + message) for logs and UX; used for both allow and deny. */
|
|
87
|
-
function formatReasons(decision) {
|
|
88
|
-
const reasons = decision.reasons || [];
|
|
89
|
-
const primaryMessage = reasons[0]?.message || decision.reason || "";
|
|
90
|
-
const codes = reasons.map((r) => r.code).filter(Boolean);
|
|
91
|
-
const codeList = codes.length ? codes.join(", ") : "";
|
|
92
|
-
const lines =
|
|
93
|
-
reasons.length > 0
|
|
94
|
-
? reasons
|
|
95
|
-
.map((r) => ` • ${r.code || "oap.unknown"}: ${r.message || ""}`)
|
|
96
|
-
.join("\n")
|
|
97
|
-
: primaryMessage
|
|
98
|
-
? ` • ${primaryMessage}`
|
|
99
|
-
: "";
|
|
100
|
-
return { reasons, codeList, lines, primaryMessage };
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Detect if exec is actually invoking our guardrail script (e.g. agent/skill runs
|
|
105
|
-
* "aport-guardrail.sh messaging.message.send '{}'" or "aport-guardrail.sh system.command.execute '{\"command\":\"mkdir ...\"}'").
|
|
106
|
-
* If so, return { innerToolName, innerContext } so we evaluate the inner tool's policy, not exec as a shell command.
|
|
107
|
-
* @param {string} command - params.command from exec
|
|
108
|
-
* @returns {{ innerToolName: string, innerContext: object } | null}
|
|
109
|
-
*/
|
|
110
|
-
function parseGuardrailInvocation(command) {
|
|
111
|
-
if (typeof command !== "string" || !command.includes("aport-guardrail"))
|
|
112
|
-
return null;
|
|
113
|
-
const match = command.match(
|
|
114
|
-
/aport-guardrail[^\s]*\s+(\S+)\s+['"]([\s\S]*)['"]\s*$/,
|
|
9
|
+
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
10
|
+
import { readFile } from "node:fs/promises";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { logAuditEntry } from "./audit.js";
|
|
14
|
+
import { canonicalize, formatReasons, verifyDecisionIntegrity } from "./decision.js";
|
|
15
|
+
import { evaluateLocalDecision } from "./local-evaluator.js";
|
|
16
|
+
import { mapToolToPolicy, normalizePolicyContext } from "./tool-mapping.js";
|
|
17
|
+
import { verifyViaApi } from "./api-client.js";
|
|
18
|
+
|
|
19
|
+
export { canonicalize, mapToolToPolicy, verifyDecisionIntegrity };
|
|
20
|
+
|
|
21
|
+
export default definePluginEntry({
|
|
22
|
+
id: "openclaw-aport",
|
|
23
|
+
name: "APort Guardrails",
|
|
24
|
+
description:
|
|
25
|
+
"Deterministic pre-action authorization via APort policy enforcement. Registers before_tool_call to block disallowed tools.",
|
|
26
|
+
|
|
27
|
+
register(api) {
|
|
28
|
+
const config = api.pluginConfig || {};
|
|
29
|
+
const mode = config.mode === "api" ? "api" : "local";
|
|
30
|
+
const agentId = typeof config.agentId === "string" && config.agentId ? config.agentId : null;
|
|
31
|
+
const passportFile = expandPath(config.passportFile || "~/.openclaw/aport/passport.json");
|
|
32
|
+
const apiUrl = config.apiUrl || "https://api.aport.io";
|
|
33
|
+
const apiKey = config.apiKey || undefined;
|
|
34
|
+
const failClosed = config.failClosed !== false;
|
|
35
|
+
const allowUnmappedTools = config.allowUnmappedTools !== false;
|
|
36
|
+
const mapExecToPolicy = config.mapExecToPolicy !== false;
|
|
37
|
+
|
|
38
|
+
const log = (msg) => api.logger?.info?.(msg);
|
|
39
|
+
const warn = (msg) => api.logger?.warn?.(msg);
|
|
40
|
+
const err = (msg) => api.logger?.error?.(msg);
|
|
41
|
+
|
|
42
|
+
log(
|
|
43
|
+
`[APort] Loaded: mode=${mode}, ${agentId ? `agentId=${agentId}` : `passportFile=${passportFile}`}, unmapped=${allowUnmappedTools ? "allow" : "block"}, mapExec=${mapExecToPolicy}`,
|
|
115
44
|
);
|
|
116
|
-
if (!match) return null;
|
|
117
|
-
const innerToolName = match[1];
|
|
118
|
-
let innerContext = {};
|
|
119
|
-
try {
|
|
120
|
-
const jsonStr = match[2].trim();
|
|
121
|
-
if (jsonStr) innerContext = JSON.parse(jsonStr);
|
|
122
|
-
} catch (_) {
|
|
123
|
-
return null;
|
|
124
|
-
}
|
|
125
|
-
return { innerToolName, innerContext };
|
|
126
|
-
}
|
|
127
45
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const out = [];
|
|
131
|
-
if (typeof value === "string") {
|
|
132
|
-
out.push(value);
|
|
133
|
-
} else if (Array.isArray(value)) {
|
|
134
|
-
for (const v of value) out.push(...collectStrings(v));
|
|
135
|
-
} else if (value && typeof value === "object") {
|
|
136
|
-
for (const v of Object.values(value)) out.push(...collectStrings(v));
|
|
137
|
-
}
|
|
138
|
-
return out;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Normalize context for exec / system.command.execute so the actual shell command
|
|
143
|
-
* is always in context.command. OpenClaw uses one tool "exec" for all commands (cp, mkdir, etc.);
|
|
144
|
-
* the policy checks the command string against allowed_commands, so we must pass the real command.
|
|
145
|
-
* Per https://docs.openclaw.ai/tools/exec the tool takes "command" (required). Gateway may
|
|
146
|
-
* pass it as params.command, event.input, or nested; we also fall back to first long string in params/event.
|
|
147
|
-
* @param {object} params - event.params from before_tool_call
|
|
148
|
-
* @param {object} [event] - full event in case gateway puts command on event.input/event.arguments
|
|
149
|
-
*/
|
|
150
|
-
function normalizeExecContext(params, event) {
|
|
151
|
-
const src =
|
|
152
|
-
event && typeof event === "object"
|
|
153
|
-
? { ...event, ...params }
|
|
154
|
-
: params || {};
|
|
155
|
-
if (typeof src !== "object") return { command: "" };
|
|
156
|
-
const raw =
|
|
157
|
-
src.command ??
|
|
158
|
-
src.cmd ??
|
|
159
|
-
(src.arguments &&
|
|
160
|
-
typeof src.arguments === "object" &&
|
|
161
|
-
src.arguments.command) ??
|
|
162
|
-
(src.input && typeof src.input === "object" && src.input.command) ??
|
|
163
|
-
(typeof src.input === "string" && src.input.trim().length > 0
|
|
164
|
-
? src.input
|
|
165
|
-
: null) ??
|
|
166
|
-
(src.args && typeof src.args === "object" && src.args.command) ??
|
|
167
|
-
(src.invocation &&
|
|
168
|
-
typeof src.invocation === "object" &&
|
|
169
|
-
src.invocation.command) ??
|
|
170
|
-
(src.payload && typeof src.payload === "object" && src.payload.command) ??
|
|
171
|
-
(Array.isArray(src.args) && src.args.length > 0
|
|
172
|
-
? src.args.join(" ")
|
|
173
|
-
: src.args?.[0]);
|
|
174
|
-
let full = typeof raw === "string" ? raw : raw != null ? String(raw) : "";
|
|
175
|
-
if (!full) {
|
|
176
|
-
const strings = collectStrings(src);
|
|
177
|
-
const likeCommand = (s) =>
|
|
178
|
-
typeof s === "string" && s.length > 2 && s.trim().length > 0;
|
|
179
|
-
const withSpace = strings.filter(
|
|
180
|
-
(s) => likeCommand(s) && s.includes(" "),
|
|
181
|
-
);
|
|
182
|
-
const candidate = withSpace[0] ?? strings.find(likeCommand);
|
|
183
|
-
if (candidate) full = candidate.trim();
|
|
184
|
-
}
|
|
185
|
-
const out = { ...params, command: full, full_command: full };
|
|
186
|
-
if (params && params.workdir !== undefined && out.cwd === undefined)
|
|
187
|
-
out.cwd = params.workdir;
|
|
188
|
-
return out;
|
|
189
|
-
}
|
|
46
|
+
api.on("before_tool_call", async (event) => {
|
|
47
|
+
const { toolName, params } = event;
|
|
190
48
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* before_tool_call hook - Runs before EVERY tool execution.
|
|
197
|
-
* We never reuse a previous decision: each call triggers a fresh APort verify (passport/limits may have changed).
|
|
198
|
-
*
|
|
199
|
-
* @param {object} event - { toolName, params, ... }
|
|
200
|
-
* @param {object} ctx - OpenClaw context
|
|
201
|
-
* @returns {Promise<object>} - { block?, blockReason?, params?, reasons? (OAP), reasonSummary? }
|
|
202
|
-
*/
|
|
203
|
-
api.on("before_tool_call", async (event, ctx) => {
|
|
204
|
-
const { toolName, params } = event;
|
|
205
|
-
|
|
206
|
-
try {
|
|
207
|
-
// Map OpenClaw tool names to APort policy names. If mapExecToPolicy is false, exec is unmapped (never blocked).
|
|
208
|
-
const policyName =
|
|
209
|
-
toolName === "exec" && !mapExecToPolicy
|
|
210
|
-
? null
|
|
211
|
-
: mapToolToPolicy(toolName);
|
|
49
|
+
try {
|
|
50
|
+
const policyName =
|
|
51
|
+
toolName === "exec" && !mapExecToPolicy ? null : mapToolToPolicy(toolName, params);
|
|
212
52
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
53
|
+
if (!policyName) {
|
|
54
|
+
if (allowUnmappedTools) {
|
|
55
|
+
log(`[APort] ALLOW: ${toolName} - (unmapped, no policy)`);
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
log(`[APort] BLOCKED: ${toolName} - no policy mapping (allowUnmappedTools=false)`);
|
|
59
|
+
return {
|
|
60
|
+
block: true,
|
|
61
|
+
blockReason: `🛡️ APort: Tool "${toolName}" has no policy mapping. Unmapped tools are blocked (allowUnmappedTools: false). Set allowUnmappedTools: true in config to allow unmapped custom skills and ClawHub tools.`,
|
|
62
|
+
};
|
|
218
63
|
}
|
|
219
|
-
log(
|
|
220
|
-
`[${name}] BLOCKED: ${toolName} - no policy mapping (allowUnmappedTools=false)`,
|
|
221
|
-
);
|
|
222
|
-
return {
|
|
223
|
-
block: true,
|
|
224
|
-
blockReason: `🛡️ APort: Tool "${toolName}" has no policy mapping. Unmapped tools are blocked (allowUnmappedTools: false). Set allowUnmappedTools: true in config to allow custom skills and ClawHub tools.`,
|
|
225
|
-
};
|
|
226
|
-
}
|
|
227
64
|
|
|
228
|
-
|
|
65
|
+
let effectivePolicyName = policyName;
|
|
66
|
+
let effectiveToolName = toolName;
|
|
67
|
+
let context = normalizePolicyContext(policyName, toolName, params, event);
|
|
229
68
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
let context =
|
|
236
|
-
policyName === "system.command.execute.v1"
|
|
237
|
-
? normalizeExecContext(params, event)
|
|
238
|
-
: params;
|
|
239
|
-
|
|
240
|
-
if (policyName === "system.command.execute.v1" && context.command) {
|
|
241
|
-
const guardrailInvocation = parseGuardrailInvocation(context.command);
|
|
242
|
-
if (guardrailInvocation) {
|
|
243
|
-
const { innerToolName, innerContext } = guardrailInvocation;
|
|
244
|
-
const innerPolicy = mapToolToPolicy(innerToolName);
|
|
69
|
+
const delegated = parseGuardrailInvocation(
|
|
70
|
+
effectivePolicyName === "system.command.execute.v1" ? context.command : null,
|
|
71
|
+
);
|
|
72
|
+
if (delegated) {
|
|
73
|
+
const innerPolicy = mapToolToPolicy(delegated.innerToolName, delegated.innerContext);
|
|
245
74
|
if (innerPolicy) {
|
|
246
75
|
effectivePolicyName = innerPolicy;
|
|
247
|
-
effectiveToolName = innerToolName;
|
|
248
|
-
context =
|
|
249
|
-
innerPolicy
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
`[${name}] exec delegates to inner tool: ${innerToolName} → policy: ${innerPolicy}`,
|
|
76
|
+
effectiveToolName = delegated.innerToolName;
|
|
77
|
+
context = normalizePolicyContext(
|
|
78
|
+
innerPolicy,
|
|
79
|
+
delegated.innerToolName,
|
|
80
|
+
delegated.innerContext,
|
|
81
|
+
{ params: delegated.innerContext },
|
|
254
82
|
);
|
|
255
83
|
}
|
|
256
84
|
}
|
|
257
|
-
const cmd = context.command || "";
|
|
258
|
-
log(
|
|
259
|
-
`[${name}] exec params.command → effective policy=${effectivePolicyName} context.command=${cmd ? `"${cmd.slice(0, 60)}${cmd.length > 60 ? "…" : ""}"` : "(n/a)"}`,
|
|
260
|
-
);
|
|
261
|
-
}
|
|
262
85
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
return {};
|
|
86
|
+
if (effectivePolicyName === "system.command.execute.v1") {
|
|
87
|
+
const command = typeof context.command === "string" ? context.command.trim() : "";
|
|
88
|
+
if (!command) {
|
|
89
|
+
log("[APort] ALLOW: exec - (empty command, skip)");
|
|
90
|
+
return {};
|
|
91
|
+
}
|
|
270
92
|
}
|
|
271
|
-
}
|
|
272
93
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
94
|
+
const requestContext = ensureIdempotencyKey(context);
|
|
95
|
+
const auditLogPath = join(dirname(passportFile), "audit.log");
|
|
96
|
+
const decision =
|
|
97
|
+
mode === "api"
|
|
98
|
+
? await verifyViaApi({
|
|
99
|
+
apiUrl,
|
|
100
|
+
apiKey,
|
|
101
|
+
policyName: effectivePolicyName,
|
|
102
|
+
context: requestContext,
|
|
103
|
+
passport: agentId ? null : JSON.parse(await readFile(passportFile, "utf8")),
|
|
104
|
+
agentId,
|
|
105
|
+
})
|
|
106
|
+
: evaluateLocalDecision({
|
|
107
|
+
policyName: effectivePolicyName,
|
|
108
|
+
context: requestContext,
|
|
109
|
+
passportFile,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (!verifyDecisionIntegrity(decision)) {
|
|
113
|
+
err(`[APort] Decision integrity check failed for ${effectiveToolName} - content_hash mismatch`);
|
|
114
|
+
return {
|
|
115
|
+
block: true,
|
|
116
|
+
blockReason:
|
|
117
|
+
"🛡️ APort: Decision integrity verification failed (content_hash mismatch). Possible tampering detected.",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
290
120
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
}
|
|
121
|
+
logAuditEntry(auditLogPath, {
|
|
122
|
+
tool: effectiveToolName,
|
|
123
|
+
decisionId: decision.decision_id,
|
|
124
|
+
allow: Boolean(decision.allow),
|
|
125
|
+
policy: effectivePolicyName,
|
|
126
|
+
code: decision.reasons?.[0]?.code,
|
|
127
|
+
agentId: agentId || decision.agent_id || undefined,
|
|
128
|
+
context: extractContextSummary(requestContext),
|
|
300
129
|
});
|
|
301
|
-
}
|
|
302
130
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
passport?.limits?.["system.command.execute"]?.allowed_commands;
|
|
328
|
-
warn(
|
|
329
|
-
`[${name}] Passport allowed_commands: ${JSON.stringify(allowed)} — add "*" or the command (e.g. ls) to fix. File: ${passportFile}`,
|
|
330
|
-
);
|
|
331
|
-
} catch (_) {
|
|
332
|
-
warn(
|
|
333
|
-
`[${name}] Could not read passport for diagnostic: ${passportFile}`,
|
|
334
|
-
);
|
|
335
|
-
}
|
|
336
|
-
}
|
|
131
|
+
if (!decision.allow) {
|
|
132
|
+
const { reasons, primaryMessage } = formatReasons(decision);
|
|
133
|
+
const message = primaryMessage || "Policy denied";
|
|
134
|
+
log(`[APort] BLOCKED: ${effectiveToolName} - ${message}`);
|
|
135
|
+
|
|
136
|
+
const reasonLines = reasons
|
|
137
|
+
.map((reason) => ` • ${reason.code || "oap.unknown"}: ${reason.message || ""}`)
|
|
138
|
+
.join("\n");
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
block: true,
|
|
142
|
+
blockReason: [
|
|
143
|
+
"🛡️ APort Policy Denied",
|
|
144
|
+
"",
|
|
145
|
+
`Policy: ${effectivePolicyName}`,
|
|
146
|
+
"",
|
|
147
|
+
"Reasons (OAP codes):",
|
|
148
|
+
reasonLines || ` • ${message}`,
|
|
149
|
+
"",
|
|
150
|
+
agentId
|
|
151
|
+
? `To allow this action, update limits at aport.io (hosted passport: ${agentId})`
|
|
152
|
+
: `To allow this action, update limits in your passport: ${passportFile}`,
|
|
153
|
+
].join("\n"),
|
|
154
|
+
};
|
|
337
155
|
}
|
|
338
|
-
const hint = isCommandNotAllowed
|
|
339
|
-
? "\nFor shell commands (cp, mkdir, npm, etc.), add them to limits.allowed_commands in your passport."
|
|
340
|
-
: "";
|
|
341
|
-
const passportHint = agentId
|
|
342
|
-
? `To allow this action, update limits at aport.io (hosted passport: ${agentId})`
|
|
343
|
-
: `To allow this action, update limits in your passport: ${passportFile}`;
|
|
344
|
-
const blockReason = [
|
|
345
|
-
"🛡️ APort Policy Denied",
|
|
346
|
-
"",
|
|
347
|
-
`Policy: ${effectivePolicyName}`,
|
|
348
|
-
"",
|
|
349
|
-
"Reasons (OAP codes):",
|
|
350
|
-
reasonLines || ` • ${message}`,
|
|
351
|
-
"",
|
|
352
|
-
passportHint,
|
|
353
|
-
hint,
|
|
354
|
-
].join("\n");
|
|
355
|
-
return {
|
|
356
|
-
block: true,
|
|
357
|
-
blockReason,
|
|
358
|
-
reasons,
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
156
|
|
|
362
|
-
|
|
363
|
-
reasons,
|
|
364
|
-
codeList,
|
|
365
|
-
lines: reasonLines,
|
|
366
|
-
primaryMessage,
|
|
367
|
-
} = formatReasons(decision);
|
|
368
|
-
const reasonSummary =
|
|
369
|
-
reasonLines || primaryMessage
|
|
370
|
-
? ["APort allowed", reasonLines || primaryMessage]
|
|
371
|
-
.filter(Boolean)
|
|
372
|
-
.join("\n")
|
|
373
|
-
: undefined;
|
|
374
|
-
const allowSummary = decisionLogSummary(
|
|
375
|
-
effectiveToolName,
|
|
376
|
-
effectivePolicyName,
|
|
377
|
-
context,
|
|
378
|
-
);
|
|
379
|
-
log(`[${name}] ALLOW: ${effectiveToolName} - ${allowSummary}`);
|
|
380
|
-
return {
|
|
381
|
-
reasons: decision.reasons?.length ? decision.reasons : undefined,
|
|
382
|
-
reasonSummary: reasonSummary || undefined,
|
|
383
|
-
};
|
|
384
|
-
} catch (error) {
|
|
385
|
-
err(`[${name}] Error evaluating policy: ${error.message}`);
|
|
386
|
-
|
|
387
|
-
if (failClosed) {
|
|
388
|
-
// Fail closed - block on error
|
|
389
|
-
return {
|
|
390
|
-
block: true,
|
|
391
|
-
blockReason: `🛡️ APort Policy Error (fail-closed)\n\nError: ${error.message}\n\nCheck configuration at plugins.entries.openclaw-aport.config`,
|
|
392
|
-
};
|
|
393
|
-
} else {
|
|
394
|
-
// Fail open - allow on error (not recommended)
|
|
395
|
-
warn(`[${name}] Allowing tool despite error (failClosed=false)`);
|
|
157
|
+
log(`[APort] ALLOW: ${effectiveToolName}`);
|
|
396
158
|
return {};
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
*/
|
|
405
|
-
api.on("after_tool_call", async (event, ctx) => {
|
|
406
|
-
const { toolName, params, result } = event;
|
|
407
|
-
log(`[${name}] Tool completed: ${toolName}`);
|
|
408
|
-
// Could log to audit trail here
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
log(`[${name}] Registered hooks: before_tool_call, after_tool_call`);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
/**
|
|
415
|
-
* Map OpenClaw tool names to APort policy names.
|
|
416
|
-
* Exported for tests; used by before_tool_call to decide if we run guardrail and for API mode.
|
|
417
|
-
*/
|
|
418
|
-
export function mapToolToPolicy(toolName) {
|
|
419
|
-
// Normalize tool name
|
|
420
|
-
const tool = toolName.toLowerCase();
|
|
421
|
-
|
|
422
|
-
// Git/Code operations
|
|
423
|
-
if (tool.match(/git\.(create_pr|merge|push|commit)/))
|
|
424
|
-
return "code.repository.merge.v1";
|
|
425
|
-
if (tool.startsWith("git.")) return "code.repository.merge.v1";
|
|
426
|
-
|
|
427
|
-
// System commands / exec (include bare "exec" - OpenClaw may send this for run-command tools)
|
|
428
|
-
if (tool === "exec") return "system.command.execute.v1";
|
|
429
|
-
if (tool.match(/exec\.(run|shell)/)) return "system.command.execute.v1";
|
|
430
|
-
if (tool.startsWith("exec.")) return "system.command.execute.v1";
|
|
431
|
-
if (tool.startsWith("system.command.")) return "system.command.execute.v1";
|
|
432
|
-
if (tool === "bash" || tool === "shell" || tool === "command")
|
|
433
|
-
return "system.command.execute.v1";
|
|
434
|
-
|
|
435
|
-
// Messaging
|
|
436
|
-
if (tool.startsWith("message.")) return "messaging.message.send.v1";
|
|
437
|
-
if (tool.startsWith("messaging.")) return "messaging.message.send.v1";
|
|
438
|
-
if (tool.match(/sms|whatsapp|slack|email/))
|
|
439
|
-
return "messaging.message.send.v1";
|
|
440
|
-
|
|
441
|
-
// File operations
|
|
442
|
-
if (tool === "read") return "data.file.read.v1";
|
|
443
|
-
if (tool.startsWith("file.read")) return "data.file.read.v1";
|
|
444
|
-
if (tool.startsWith("data.file.read")) return "data.file.read.v1";
|
|
445
|
-
if (tool === "write" || tool === "edit") return "data.file.write.v1";
|
|
446
|
-
if (tool.startsWith("file.write")) return "data.file.write.v1";
|
|
447
|
-
if (tool.startsWith("file.edit")) return "data.file.write.v1";
|
|
448
|
-
if (tool.startsWith("data.file.write")) return "data.file.write.v1";
|
|
449
|
-
|
|
450
|
-
// MCP tools
|
|
451
|
-
if (tool.startsWith("mcp.")) return "mcp.tool.execute.v1";
|
|
452
|
-
|
|
453
|
-
// Agent sessions
|
|
454
|
-
if (tool.match(/agent\.session|session\.create/))
|
|
455
|
-
return "agent.session.create.v1";
|
|
456
|
-
if (tool.startsWith("session.")) return "agent.session.create.v1";
|
|
457
|
-
|
|
458
|
-
// Tool registration
|
|
459
|
-
if (tool.match(/agent\.tool|tool\.register/)) return "agent.tool.register.v1";
|
|
460
|
-
|
|
461
|
-
// Financial operations
|
|
462
|
-
if (tool.match(/payment\.refund|refund/)) return "finance.payment.refund.v1";
|
|
463
|
-
if (tool.match(/payment\.charge|charge/)) return "finance.payment.charge.v1";
|
|
464
|
-
if (tool.startsWith("finance.")) return "finance.payment.refund.v1";
|
|
465
|
-
|
|
466
|
-
// Data operations
|
|
467
|
-
if (tool.match(/database\.(write|insert|update|delete)/))
|
|
468
|
-
return "data.export.create.v1";
|
|
469
|
-
if (tool.match(/data\.export|export/)) return "data.export.create.v1";
|
|
470
|
-
|
|
471
|
-
// No mapping found
|
|
472
|
-
return null;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
/**
|
|
476
|
-
* Canonicalize object for hashing (sort keys at every level, like jq -c -S).
|
|
477
|
-
* Must match guardrail script's jq --sort-keys output so content_hash verifies.
|
|
478
|
-
* Exported for tests.
|
|
479
|
-
*/
|
|
480
|
-
export function canonicalize(obj) {
|
|
481
|
-
if (obj === null || typeof obj !== "object") return JSON.stringify(obj);
|
|
482
|
-
if (Array.isArray(obj)) return "[" + obj.map(canonicalize).join(",") + "]";
|
|
483
|
-
const keys = Object.keys(obj).sort();
|
|
484
|
-
const parts = keys.map((k) => JSON.stringify(k) + ":" + canonicalize(obj[k]));
|
|
485
|
-
return "{" + parts.join(",") + "}";
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/**
|
|
489
|
-
* Verify local decision file integrity (content_hash). Returns true if valid or no hash (legacy).
|
|
490
|
-
* If the file was edited or moved, the hash will not match. Exported for tests.
|
|
491
|
-
*/
|
|
492
|
-
export function verifyDecisionIntegrity(decision) {
|
|
493
|
-
if (!decision || !decision.content_hash) return true;
|
|
494
|
-
const { content_hash, ...rest } = decision;
|
|
495
|
-
const canonical = canonicalize(rest);
|
|
496
|
-
const computed =
|
|
497
|
-
"sha256:" + createHash("sha256").update(canonical, "utf8").digest("hex");
|
|
498
|
-
return computed === content_hash;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
/**
|
|
502
|
-
* Verify action via local guardrail script.
|
|
503
|
-
* toolName must match the script's case patterns (e.g. system.command.execute, messaging.message.send); the plugin passes the policy-derived name (policy id without .v1) so "exec" is not passed (script would treat it as unknown).
|
|
504
|
-
* Decisions are written under config dir (decisions/) with content_hash and chain (prev_*);
|
|
505
|
-
* they are left for audit and are tamper-resistant (edit or reorder breaks verification).
|
|
506
|
-
*/
|
|
507
|
-
async function verifyViaScript(
|
|
508
|
-
toolName,
|
|
509
|
-
params,
|
|
510
|
-
{ guardrailScript, passportFile },
|
|
511
|
-
) {
|
|
512
|
-
const contextJson = JSON.stringify(params);
|
|
513
|
-
// Unique decision file per invocation — no cache, no reuse. We only read the file we pass here.
|
|
514
|
-
const configDir = dirname(passportFile);
|
|
515
|
-
const decisionsDir = join(configDir, "decisions");
|
|
516
|
-
await mkdir(decisionsDir, { recursive: true });
|
|
517
|
-
const decisionFile = join(
|
|
518
|
-
decisionsDir,
|
|
519
|
-
`${Date.now()}-${Math.random().toString(36).slice(2, 10)}.json`,
|
|
520
|
-
);
|
|
521
|
-
|
|
522
|
-
return new Promise((resolve, reject) => {
|
|
523
|
-
const proc = spawn(guardrailScript, [toolName, contextJson], {
|
|
524
|
-
env: {
|
|
525
|
-
...process.env,
|
|
526
|
-
OPENCLAW_PASSPORT_FILE: passportFile,
|
|
527
|
-
OPENCLAW_DECISION_FILE: decisionFile,
|
|
528
|
-
OPENCLAW_AUDIT_LOG: join(configDir, "audit.log"),
|
|
529
|
-
},
|
|
530
|
-
});
|
|
531
|
-
|
|
532
|
-
let stdout = "";
|
|
533
|
-
let stderr = "";
|
|
534
|
-
|
|
535
|
-
proc.stdout.on("data", (data) => (stdout += data));
|
|
536
|
-
proc.stderr.on("data", (data) => (stderr += data));
|
|
537
|
-
|
|
538
|
-
proc.on("close", async (code) => {
|
|
539
|
-
// Always read the decision file we passed to this invocation only (script writes to it before exit).
|
|
540
|
-
try {
|
|
541
|
-
const decisionData = await readFile(decisionFile, "utf8");
|
|
542
|
-
const decision = JSON.parse(decisionData);
|
|
543
|
-
resolve(decision);
|
|
544
|
-
} catch (err) {
|
|
545
|
-
if (code === 0) {
|
|
546
|
-
resolve({ allow: true });
|
|
547
|
-
} else {
|
|
548
|
-
resolve({
|
|
549
|
-
allow: false,
|
|
550
|
-
reasons: [
|
|
551
|
-
{ message: stderr || `Tool ${toolName} denied (exit ${code})` },
|
|
552
|
-
],
|
|
553
|
-
});
|
|
159
|
+
} catch (error) {
|
|
160
|
+
err(`[APort] Error evaluating policy: ${error.message}`);
|
|
161
|
+
if (failClosed) {
|
|
162
|
+
return {
|
|
163
|
+
block: true,
|
|
164
|
+
blockReason: `🛡️ APort Policy Error (fail-closed)\n\nError: ${error.message}\n\nCheck configuration at plugins.entries.openclaw-aport.config`,
|
|
165
|
+
};
|
|
554
166
|
}
|
|
167
|
+
warn("[APort] Allowing tool despite error (failClosed=false)");
|
|
168
|
+
return {};
|
|
555
169
|
}
|
|
556
170
|
});
|
|
557
171
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
});
|
|
562
|
-
}
|
|
172
|
+
log("[APort] Registered hooks: before_tool_call");
|
|
173
|
+
},
|
|
174
|
+
});
|
|
563
175
|
|
|
564
|
-
/** Generate an idempotency key (10–64 chars, alphanumeric + hyphen/underscore) for API requests that require it. */
|
|
565
176
|
function ensureIdempotencyKey(context) {
|
|
566
177
|
if (context && context.idempotency_key) return context;
|
|
567
178
|
const ts = Date.now().toString(36);
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
|
|
179
|
+
const rand = Math.random().toString(36).slice(2, 10);
|
|
180
|
+
return {
|
|
181
|
+
...context,
|
|
182
|
+
idempotency_key: `idem_${ts}_${rand}`.slice(0, 64),
|
|
183
|
+
};
|
|
571
184
|
}
|
|
572
185
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
async function verifyViaAPI(
|
|
578
|
-
policyName,
|
|
579
|
-
params,
|
|
580
|
-
{ apiUrl, apiKey, passportFile, agentId },
|
|
581
|
-
) {
|
|
582
|
-
try {
|
|
583
|
-
const context = ensureIdempotencyKey(params);
|
|
584
|
-
|
|
585
|
-
const url = `${apiUrl}/api/verify/policy/${policyName}`;
|
|
586
|
-
const headers = {
|
|
587
|
-
"Content-Type": "application/json",
|
|
588
|
-
};
|
|
589
|
-
if (apiKey) {
|
|
590
|
-
headers["Authorization"] = `Bearer ${apiKey}`;
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
let body;
|
|
594
|
-
if (agentId) {
|
|
595
|
-
body = JSON.stringify({
|
|
596
|
-
context: { agent_id: agentId, ...context },
|
|
597
|
-
});
|
|
598
|
-
} else {
|
|
599
|
-
const passportData = await readFile(passportFile, "utf8");
|
|
600
|
-
const passport = JSON.parse(passportData);
|
|
601
|
-
body = JSON.stringify({
|
|
602
|
-
passport,
|
|
603
|
-
context,
|
|
604
|
-
});
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
const response = await fetch(url, {
|
|
608
|
-
method: "POST",
|
|
609
|
-
headers,
|
|
610
|
-
body,
|
|
611
|
-
});
|
|
612
|
-
|
|
613
|
-
if (!response.ok) {
|
|
614
|
-
throw new Error(
|
|
615
|
-
`API request failed: ${response.status} ${response.statusText}`,
|
|
616
|
-
);
|
|
617
|
-
}
|
|
186
|
+
function expandPath(value) {
|
|
187
|
+
if (value.startsWith("~/")) return join(homedir(), value.slice(2));
|
|
188
|
+
return value;
|
|
189
|
+
}
|
|
618
190
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
191
|
+
function extractContextSummary(context) {
|
|
192
|
+
if (typeof context?.command === "string" && context.command) return context.command;
|
|
193
|
+
if (typeof context?.file_path === "string" && context.file_path) return context.file_path;
|
|
194
|
+
if (typeof context?.recipient === "string" && context.recipient) return context.recipient;
|
|
195
|
+
if (typeof context?.url === "string" && context.url) return context.url;
|
|
196
|
+
return undefined;
|
|
624
197
|
}
|
|
625
198
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
return
|
|
199
|
+
function parseGuardrailInvocation(command) {
|
|
200
|
+
if (typeof command !== "string" || !command.includes("aport-guardrail")) return null;
|
|
201
|
+
const match = command.match(/aport-guardrail[^\s]*\s+(\S+)\s+['"]([\s\S]*)['"]\s*$/);
|
|
202
|
+
if (!match) return null;
|
|
203
|
+
try {
|
|
204
|
+
return {
|
|
205
|
+
innerToolName: match[1],
|
|
206
|
+
innerContext: match[2].trim() ? JSON.parse(match[2]) : {},
|
|
207
|
+
};
|
|
208
|
+
} catch {
|
|
209
|
+
return null;
|
|
632
210
|
}
|
|
633
|
-
return path;
|
|
634
211
|
}
|