@plusplus7/clawclamp 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/LICENSE +21 -0
- package/README.md +94 -0
- package/README.zh-CN.md +94 -0
- package/RELEASE.md +34 -0
- package/assets/app.js +362 -0
- package/assets/index.html +125 -0
- package/assets/styles.css +432 -0
- package/index.ts +45 -0
- package/openclaw.plugin.json +10 -0
- package/package.json +17 -0
- package/src/audit.ts +94 -0
- package/src/cedarling.ts +46 -0
- package/src/config.ts +249 -0
- package/src/grants.ts +183 -0
- package/src/guard.test.ts +69 -0
- package/src/guard.ts +342 -0
- package/src/http.ts +433 -0
- package/src/mode.ts +48 -0
- package/src/policy-store.ts +186 -0
- package/src/policy.ts +74 -0
- package/src/storage.ts +23 -0
- package/src/types.ts +63 -0
package/src/guard.ts
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import type {
|
|
3
|
+
PluginHookAfterToolCallEvent,
|
|
4
|
+
PluginHookBeforeToolCallEvent,
|
|
5
|
+
PluginHookToolContext,
|
|
6
|
+
} from "openclaw/plugins/types";
|
|
7
|
+
import type { CedarlingConfig, CedarlingInstance } from "./cedarling.js";
|
|
8
|
+
import { getCedarling, resetCedarlingInstance } from "./cedarling.js";
|
|
9
|
+
import { appendAuditEntry, createAuditEntryId } from "./audit.js";
|
|
10
|
+
import { getModeOverride } from "./mode.js";
|
|
11
|
+
import { buildDefaultPolicyStore } from "./policy.js";
|
|
12
|
+
import { ensurePolicyStore } from "./policy-store.js";
|
|
13
|
+
import type { AuditEntry, ClawClampConfig, ClawClampMode, RiskLevel } from "./types.js";
|
|
14
|
+
|
|
15
|
+
type CedarDecision = "allow" | "deny" | "error";
|
|
16
|
+
|
|
17
|
+
type CedarEvaluation = {
|
|
18
|
+
decision: CedarDecision;
|
|
19
|
+
reason?: string;
|
|
20
|
+
raw?: Record<string, unknown>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function resolveRisk(toolName: string, config: ClawClampConfig): RiskLevel {
|
|
24
|
+
const override = config.risk.overrides[toolName];
|
|
25
|
+
return override ?? config.risk.default;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function summarizeParams(
|
|
29
|
+
params: Record<string, unknown>,
|
|
30
|
+
config: ClawClampConfig,
|
|
31
|
+
): Record<string, unknown> | string {
|
|
32
|
+
if (!config.audit.includeParams) {
|
|
33
|
+
return "(params omitted)";
|
|
34
|
+
}
|
|
35
|
+
const summary: Record<string, unknown> = {};
|
|
36
|
+
for (const [key, value] of Object.entries(params)) {
|
|
37
|
+
if (typeof value === "string") {
|
|
38
|
+
summary[key] =
|
|
39
|
+
value.length > config.audit.maxParamLength
|
|
40
|
+
? `${value.slice(0, config.audit.maxParamLength)}...`
|
|
41
|
+
: value;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (typeof value === "number" || typeof value === "boolean" || value === null) {
|
|
45
|
+
summary[key] = value;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (Array.isArray(value)) {
|
|
49
|
+
summary[key] = `Array(${value.length})`;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (typeof value === "object" && value) {
|
|
53
|
+
summary[key] = `Object(${Object.keys(value).length})`;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
summary[key] = String(value);
|
|
57
|
+
}
|
|
58
|
+
return summary;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function buildCedarlingConfig(params: {
|
|
62
|
+
config: ClawClampConfig;
|
|
63
|
+
policyStoreLocal?: string;
|
|
64
|
+
}): CedarlingConfig {
|
|
65
|
+
const policyStoreLocal =
|
|
66
|
+
params.policyStoreLocal ?? params.config.policyStoreLocal ?? JSON.stringify(buildDefaultPolicyStore());
|
|
67
|
+
|
|
68
|
+
const cedarConfig: CedarlingConfig = {
|
|
69
|
+
CEDARLING_APPLICATION_NAME: "openclaw-clawclamp",
|
|
70
|
+
CEDARLING_USER_AUTHZ: "enabled",
|
|
71
|
+
CEDARLING_WORKLOAD_AUTHZ: "disabled",
|
|
72
|
+
CEDARLING_JWT_SIG_VALIDATION: "disabled",
|
|
73
|
+
CEDARLING_LOG_TYPE: "std_out",
|
|
74
|
+
CEDARLING_LOG_LEVEL: "WARN",
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
if (params.config.policyStoreUri) {
|
|
78
|
+
cedarConfig.CEDARLING_POLICY_STORE_URI = params.config.policyStoreUri;
|
|
79
|
+
} else {
|
|
80
|
+
cedarConfig.CEDARLING_POLICY_STORE_LOCAL = policyStoreLocal;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return cedarConfig;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseDecisionValue(value: unknown, raw?: Record<string, unknown>): CedarEvaluation | null {
|
|
87
|
+
if (typeof value === "string") {
|
|
88
|
+
const normalized = value.toLowerCase();
|
|
89
|
+
if (normalized.includes("allow") || normalized.includes("permit")) {
|
|
90
|
+
return { decision: "allow", raw };
|
|
91
|
+
}
|
|
92
|
+
if (normalized.includes("deny") || normalized.includes("forbid")) {
|
|
93
|
+
return { decision: "deny", raw };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (typeof value === "boolean") {
|
|
97
|
+
return { decision: value ? "allow" : "deny", raw };
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function cedarEntityUid(type: string, id: string): string {
|
|
103
|
+
return `${type}::${JSON.stringify(id)}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseCedarDecision(
|
|
107
|
+
raw: unknown,
|
|
108
|
+
principalType: string,
|
|
109
|
+
principalId: string,
|
|
110
|
+
): CedarEvaluation {
|
|
111
|
+
if (!raw || typeof raw !== "object") {
|
|
112
|
+
return { decision: "error", reason: "Cedar response missing" };
|
|
113
|
+
}
|
|
114
|
+
const record = raw as Record<string, unknown>;
|
|
115
|
+
const principals = record.principals;
|
|
116
|
+
if (principals && typeof principals === "object" && !Array.isArray(principals)) {
|
|
117
|
+
const principalRecord = principals as Record<string, unknown>;
|
|
118
|
+
const exact = principalRecord[cedarEntityUid(principalType, principalId)];
|
|
119
|
+
if (exact && typeof exact === "object") {
|
|
120
|
+
const nested = parseDecisionValue((exact as Record<string, unknown>).decision, record);
|
|
121
|
+
if (nested) {
|
|
122
|
+
return nested;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const typed = principalRecord[principalType];
|
|
126
|
+
if (typed && typeof typed === "object") {
|
|
127
|
+
const nested = parseDecisionValue((typed as Record<string, unknown>).decision, record);
|
|
128
|
+
if (nested) {
|
|
129
|
+
return nested;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const topLevel = parseDecisionValue(
|
|
134
|
+
(record.decision as unknown) ?? (record.decisionId as unknown) ?? (record.result as unknown),
|
|
135
|
+
record,
|
|
136
|
+
);
|
|
137
|
+
if (topLevel) {
|
|
138
|
+
return topLevel;
|
|
139
|
+
}
|
|
140
|
+
return { decision: "error", reason: "Unknown Cedar decision", raw: record };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function evaluateCedar(params: {
|
|
144
|
+
api: OpenClawPluginApi;
|
|
145
|
+
cedarling: CedarlingInstance;
|
|
146
|
+
config: ClawClampConfig;
|
|
147
|
+
toolName: string;
|
|
148
|
+
risk: RiskLevel;
|
|
149
|
+
}): Promise<CedarEvaluation> {
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
const request = {
|
|
152
|
+
principals: [
|
|
153
|
+
{
|
|
154
|
+
type: "User",
|
|
155
|
+
id: params.config.principalId,
|
|
156
|
+
role: "operator",
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
action: 'Action::"Invoke"',
|
|
160
|
+
resource: {
|
|
161
|
+
type: "Tool",
|
|
162
|
+
id: params.toolName,
|
|
163
|
+
name: params.toolName,
|
|
164
|
+
risk: params.risk,
|
|
165
|
+
},
|
|
166
|
+
context: {
|
|
167
|
+
now,
|
|
168
|
+
tool: params.toolName,
|
|
169
|
+
risk: params.risk,
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
params.api.logger.info(
|
|
173
|
+
`[clawclamp] cedar request ${JSON.stringify(request)}`,
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const result = await params.cedarling.authorize_unsigned(request);
|
|
178
|
+
const jsonString = result.json_string();
|
|
179
|
+
params.api.logger.info(
|
|
180
|
+
`[clawclamp] cedar response ${JSON.stringify({ request, result: jsonString })}`,
|
|
181
|
+
);
|
|
182
|
+
const parsed = JSON.parse(jsonString) as unknown;
|
|
183
|
+
return parseCedarDecision(parsed, "User", params.config.principalId);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
return { decision: "error", reason: error instanceof Error ? error.message : String(error) };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export class ClawClampService {
|
|
190
|
+
private readonly config: ClawClampConfig;
|
|
191
|
+
private readonly stateDir: string;
|
|
192
|
+
private readonly api: OpenClawPluginApi;
|
|
193
|
+
private cedarlingPromise: Promise<CedarlingInstance> | null = null;
|
|
194
|
+
private cedarlingPolicyStoreJson: string | null = null;
|
|
195
|
+
|
|
196
|
+
constructor(params: { api: OpenClawPluginApi; config: ClawClampConfig; stateDir: string }) {
|
|
197
|
+
this.api = params.api;
|
|
198
|
+
this.config = params.config;
|
|
199
|
+
this.stateDir = params.stateDir;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async handleBeforeToolCall(
|
|
203
|
+
event: PluginHookBeforeToolCallEvent,
|
|
204
|
+
ctx: PluginHookToolContext,
|
|
205
|
+
): Promise<{ block?: boolean; blockReason?: string } | undefined> {
|
|
206
|
+
const toolName = event.toolName;
|
|
207
|
+
const risk = resolveRisk(toolName, this.config);
|
|
208
|
+
const paramsSummary = summarizeParams(event.params, this.config);
|
|
209
|
+
const auditId = createAuditEntryId();
|
|
210
|
+
const mode = await this.getEffectiveMode();
|
|
211
|
+
|
|
212
|
+
let cedarDecision: CedarEvaluation;
|
|
213
|
+
if (this.config.enabled) {
|
|
214
|
+
try {
|
|
215
|
+
const cedarling = await this.getCedarlingInstance();
|
|
216
|
+
cedarDecision = await evaluateCedar({
|
|
217
|
+
api: this.api,
|
|
218
|
+
cedarling,
|
|
219
|
+
config: this.config,
|
|
220
|
+
toolName,
|
|
221
|
+
risk,
|
|
222
|
+
});
|
|
223
|
+
} catch (error) {
|
|
224
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
225
|
+
this.api.logger.error(`Clawclamp authorization failed for ${toolName}: ${reason}`);
|
|
226
|
+
cedarDecision = { decision: "error", reason };
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
cedarDecision = { decision: "allow", reason: "Clawclamp disabled" };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let finalDecision: "allow" | "deny" | "allow_grayed" | "error" = "allow";
|
|
233
|
+
let block = false;
|
|
234
|
+
let reason: string | undefined;
|
|
235
|
+
|
|
236
|
+
if (cedarDecision.decision === "error") {
|
|
237
|
+
finalDecision = "error";
|
|
238
|
+
reason = cedarDecision.reason ?? "Cedar evaluation failed";
|
|
239
|
+
if (!this.config.policyFailOpen) {
|
|
240
|
+
block = true;
|
|
241
|
+
}
|
|
242
|
+
} else if (cedarDecision.decision === "deny") {
|
|
243
|
+
if (mode === "gray") {
|
|
244
|
+
finalDecision = "allow_grayed";
|
|
245
|
+
} else {
|
|
246
|
+
finalDecision = "deny";
|
|
247
|
+
block = true;
|
|
248
|
+
}
|
|
249
|
+
reason = cedarDecision.reason ?? "Denied by Cedar policy";
|
|
250
|
+
} else {
|
|
251
|
+
finalDecision = "allow";
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const entry: AuditEntry = {
|
|
255
|
+
id: auditId,
|
|
256
|
+
timestamp: new Date().toISOString(),
|
|
257
|
+
toolName,
|
|
258
|
+
toolCallId: event.toolCallId,
|
|
259
|
+
runId: event.runId,
|
|
260
|
+
sessionId: ctx.sessionId,
|
|
261
|
+
sessionKey: ctx.sessionKey,
|
|
262
|
+
agentId: ctx.agentId,
|
|
263
|
+
risk,
|
|
264
|
+
cedarDecision: cedarDecision.decision === "error" ? "error" : cedarDecision.decision,
|
|
265
|
+
decision: finalDecision,
|
|
266
|
+
reason,
|
|
267
|
+
params: paramsSummary,
|
|
268
|
+
grayMode: mode === "gray",
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
await appendAuditEntry(this.stateDir, entry);
|
|
272
|
+
|
|
273
|
+
if (block) {
|
|
274
|
+
return { block: true, blockReason: reason ?? "Tool call blocked" };
|
|
275
|
+
}
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async handleAfterToolCall(
|
|
280
|
+
event: PluginHookAfterToolCallEvent,
|
|
281
|
+
ctx: PluginHookToolContext,
|
|
282
|
+
): Promise<void> {
|
|
283
|
+
const entry: AuditEntry = {
|
|
284
|
+
id: createAuditEntryId(),
|
|
285
|
+
timestamp: new Date().toISOString(),
|
|
286
|
+
toolName: event.toolName,
|
|
287
|
+
toolCallId: event.toolCallId,
|
|
288
|
+
runId: event.runId,
|
|
289
|
+
sessionId: ctx.sessionId,
|
|
290
|
+
sessionKey: ctx.sessionKey,
|
|
291
|
+
agentId: ctx.agentId,
|
|
292
|
+
risk: resolveRisk(event.toolName, this.config),
|
|
293
|
+
decision: "allow",
|
|
294
|
+
resultStatus: event.error ? "error" : "ok",
|
|
295
|
+
error: event.error,
|
|
296
|
+
durationMs: event.durationMs,
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
await appendAuditEntry(this.stateDir, entry);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async getEffectiveMode(): Promise<ClawClampMode> {
|
|
303
|
+
const override = await getModeOverride(this.stateDir);
|
|
304
|
+
return override ?? this.config.mode;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private async getCedarlingInstance(): Promise<CedarlingInstance> {
|
|
308
|
+
const policyStore = await ensurePolicyStore({ stateDir: this.stateDir, config: this.config });
|
|
309
|
+
if (
|
|
310
|
+
!this.cedarlingPromise ||
|
|
311
|
+
(!policyStore.readOnly && policyStore.json && policyStore.json !== this.cedarlingPolicyStoreJson)
|
|
312
|
+
) {
|
|
313
|
+
const cedarConfig = buildCedarlingConfig({
|
|
314
|
+
config: this.config,
|
|
315
|
+
policyStoreLocal: policyStore.readOnly ? undefined : policyStore.json,
|
|
316
|
+
});
|
|
317
|
+
resetCedarlingInstance();
|
|
318
|
+
this.cedarlingPromise = getCedarling(cedarConfig);
|
|
319
|
+
this.cedarlingPolicyStoreJson = policyStore.readOnly ? null : (policyStore.json ?? null);
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
return await this.cedarlingPromise;
|
|
323
|
+
} catch (error) {
|
|
324
|
+
this.api.logger.error(`Failed to initialize Cedarling: ${String(error)}`);
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async resetCedarling(): Promise<void> {
|
|
330
|
+
this.cedarlingPromise = null;
|
|
331
|
+
this.cedarlingPolicyStoreJson = null;
|
|
332
|
+
resetCedarlingInstance();
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function createClawClampService(params: {
|
|
337
|
+
api: OpenClawPluginApi;
|
|
338
|
+
config: ClawClampConfig;
|
|
339
|
+
stateDir: string;
|
|
340
|
+
}): ClawClampService {
|
|
341
|
+
return new ClawClampService(params);
|
|
342
|
+
}
|