clawclamp 0.1.7 → 0.1.9
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 +4 -3
- package/assets/app.js +50 -12
- package/assets/styles.css +20 -0
- package/package.json +1 -1
- package/src/cedarling.ts +16 -1
- package/src/config.ts +1 -1
- package/src/guard.ts +1 -6
- package/src/http.ts +0 -35
- package/src/policy.ts +1 -5
- package/src/denies.ts +0 -75
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Clawclamp adds Cedar-based authorization to OpenClaw tool calls. It evaluates ev
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- Cedar policy enforcement for `before_tool_call`.
|
|
8
|
-
- Long-term authorization via policy (
|
|
8
|
+
- Long-term authorization via policy (default policy denies all unless a grant is active).
|
|
9
9
|
- Short-term authorization via expiring grants.
|
|
10
10
|
- Audit UI for allowed/denied/gray-mode tool calls.
|
|
11
11
|
- Gray mode: denied calls are still executed but logged as overrides.
|
|
@@ -20,7 +20,7 @@ plugins:
|
|
|
20
20
|
clawclamp:
|
|
21
21
|
enabled: true
|
|
22
22
|
config:
|
|
23
|
-
mode:
|
|
23
|
+
mode: gray
|
|
24
24
|
principalId: openclaw
|
|
25
25
|
policyStoreUri: file:///path/to/policy-store.json
|
|
26
26
|
policyFailOpen: false
|
|
@@ -40,7 +40,7 @@ plugins:
|
|
|
40
40
|
includeParams: true
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
`policyStoreUri` points to a Cedar policy store JSON (file:// or https://). `policyStoreLocal` can be set to a raw JSON string for the policy store. If omitted, the plugin uses a built-in policy store that
|
|
43
|
+
`policyStoreUri` points to a Cedar policy store JSON (file:// or https://). `policyStoreLocal` can be set to a raw JSON string for the policy store. If omitted, the plugin uses a built-in policy store that denies all tool calls unless a grant is active or an explicit permit policy exists.
|
|
44
44
|
|
|
45
45
|
## UI
|
|
46
46
|
|
|
@@ -55,3 +55,4 @@ Policy management:
|
|
|
55
55
|
|
|
56
56
|
- The UI includes a Cedar policy panel for CRUD.
|
|
57
57
|
- If `policyStoreUri` is set, policies are read-only.
|
|
58
|
+
- Default policy set is empty, so all tool calls are denied unless you add permit policies.
|
package/assets/app.js
CHANGED
|
@@ -120,8 +120,17 @@ function renderAudit(entries) {
|
|
|
120
120
|
row.className = "table-row";
|
|
121
121
|
const badgeClass = decisionBadge(entry.decision);
|
|
122
122
|
const canAllow = entry.decision === "deny" || entry.decision === "allow_grayed";
|
|
123
|
-
const
|
|
124
|
-
const
|
|
123
|
+
const canDeny = entry.decision === "allow";
|
|
124
|
+
const paramsText =
|
|
125
|
+
entry.params && typeof entry.params === "object"
|
|
126
|
+
? JSON.stringify(entry.params, null, 2)
|
|
127
|
+
: entry.params || "";
|
|
128
|
+
const meta = [
|
|
129
|
+
entry.toolCallId ? `toolCallId: ${entry.toolCallId}` : null,
|
|
130
|
+
entry.runId ? `runId: ${entry.runId}` : null,
|
|
131
|
+
entry.sessionKey ? `sessionKey: ${entry.sessionKey}` : null,
|
|
132
|
+
entry.agentId ? `agentId: ${entry.agentId}` : null,
|
|
133
|
+
].filter(Boolean);
|
|
125
134
|
row.innerHTML = `
|
|
126
135
|
<div>${formatTime(entry.timestamp)}</div>
|
|
127
136
|
<div class="mono">${entry.toolName}</div>
|
|
@@ -132,24 +141,22 @@ function renderAudit(entries) {
|
|
|
132
141
|
${canAllow ? "<button class=\"btn mini\" data-action=\"allow\">一键允许</button>" : ""}
|
|
133
142
|
${canDeny ? "<button class=\"btn mini warn\" data-action=\"deny\">一键拒绝</button>" : ""}
|
|
134
143
|
</div>
|
|
144
|
+
<div class="audit-detail">
|
|
145
|
+
<div class="audit-meta">${meta.join(" · ")}</div>
|
|
146
|
+
${paramsText ? `<pre>${paramsText}</pre>` : ""}
|
|
147
|
+
</div>
|
|
135
148
|
`;
|
|
136
149
|
const allowBtn = row.querySelector("button[data-action=\"allow\"]");
|
|
137
150
|
if (allowBtn) {
|
|
138
151
|
allowBtn.addEventListener("click", async () => {
|
|
139
|
-
await
|
|
140
|
-
method: "POST",
|
|
141
|
-
body: JSON.stringify({ toolName: entry.toolName }),
|
|
142
|
-
});
|
|
152
|
+
await applyPolicyChange("permit", entry.toolName);
|
|
143
153
|
await refreshAll();
|
|
144
154
|
});
|
|
145
155
|
}
|
|
146
156
|
const denyBtn = row.querySelector("button[data-action=\"deny\"]");
|
|
147
157
|
if (denyBtn) {
|
|
148
158
|
denyBtn.addEventListener("click", async () => {
|
|
149
|
-
await
|
|
150
|
-
method: "POST",
|
|
151
|
-
body: JSON.stringify({ toolName: entry.toolName }),
|
|
152
|
-
});
|
|
159
|
+
await applyPolicyChange("forbid", entry.toolName);
|
|
153
160
|
await refreshAll();
|
|
154
161
|
});
|
|
155
162
|
}
|
|
@@ -195,8 +202,6 @@ async function refreshAll() {
|
|
|
195
202
|
renderMode(state);
|
|
196
203
|
const grants = await fetchJson(`${API_BASE}/grants`);
|
|
197
204
|
renderGrants(grants.grants || []);
|
|
198
|
-
const denies = await fetchJson(`${API_BASE}/denies`);
|
|
199
|
-
deniedTools = new Set(denies.tools || []);
|
|
200
205
|
const logs = await fetchJson(`${API_BASE}/logs`);
|
|
201
206
|
renderAudit(logs.entries || []);
|
|
202
207
|
await refreshPolicies();
|
|
@@ -240,6 +245,39 @@ grantForm.addEventListener("submit", async (event) => {
|
|
|
240
245
|
refreshAll();
|
|
241
246
|
setInterval(refreshAll, 10_000);
|
|
242
247
|
|
|
248
|
+
function sanitizeIdPart(value) {
|
|
249
|
+
return value.toLowerCase().replace(/[^a-z0-9_-]+/g, "_").slice(0, 64);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function policyIdPrefix(effect, toolName) {
|
|
253
|
+
return `ui-${effect}:${sanitizeIdPart(toolName)}:`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function policyBody(effect, toolName) {
|
|
257
|
+
const keyword = effect === "forbid" ? "forbid" : "permit";
|
|
258
|
+
return `${keyword}(principal, action, resource)\nwhen {\n action == Action::\"Invoke\" && resource.name == \"${toolName}\"\n};`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function removePoliciesByPrefix(prefix) {
|
|
262
|
+
const targets = policies.filter((policy) => policy.id.startsWith(prefix));
|
|
263
|
+
for (const policy of targets) {
|
|
264
|
+
await fetchJson(`${API_BASE}/policies/${encodeURIComponent(policy.id)}`, {
|
|
265
|
+
method: "DELETE",
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function applyPolicyChange(effect, toolName) {
|
|
271
|
+
const opposite = effect === "permit" ? "forbid" : "permit";
|
|
272
|
+
await removePoliciesByPrefix(policyIdPrefix(opposite, toolName));
|
|
273
|
+
const id = `${policyIdPrefix(effect, toolName)}${Date.now()}`;
|
|
274
|
+
const content = policyBody(effect, toolName);
|
|
275
|
+
await fetchJson(`${API_BASE}/policies`, {
|
|
276
|
+
method: "POST",
|
|
277
|
+
body: JSON.stringify({ id, content }),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
243
281
|
policyCreateBtn.addEventListener("click", async () => {
|
|
244
282
|
const id = policyIdInput.value.trim();
|
|
245
283
|
const content = policyContentInput.value.trim();
|
package/assets/styles.css
CHANGED
|
@@ -232,6 +232,26 @@ input {
|
|
|
232
232
|
flex-wrap: wrap;
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
.audit-detail {
|
|
236
|
+
grid-column: 1 / -1;
|
|
237
|
+
border-top: 1px dashed var(--border);
|
|
238
|
+
margin-top: 6px;
|
|
239
|
+
padding-top: 6px;
|
|
240
|
+
font-size: 0.72rem;
|
|
241
|
+
color: var(--muted);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.audit-detail pre {
|
|
245
|
+
margin: 6px 0 0;
|
|
246
|
+
padding: 8px;
|
|
247
|
+
border-radius: 6px;
|
|
248
|
+
border: 1px solid var(--border);
|
|
249
|
+
background: #f3f5f9;
|
|
250
|
+
font-size: 0.72rem;
|
|
251
|
+
line-height: 1.35;
|
|
252
|
+
overflow: auto;
|
|
253
|
+
}
|
|
254
|
+
|
|
235
255
|
.policy-grid {
|
|
236
256
|
display: grid;
|
|
237
257
|
grid-template-columns: 220px 1fr;
|
package/package.json
CHANGED
package/src/cedarling.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import initWasm, { init } from "@janssenproject/cedarling_wasm";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
2
4
|
|
|
3
5
|
export type CedarlingConfig = Record<string, string | number | boolean>;
|
|
4
6
|
|
|
@@ -11,7 +13,9 @@ export type CedarlingInstance = {
|
|
|
11
13
|
pop_logs: () => unknown[];
|
|
12
14
|
};
|
|
13
15
|
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
14
17
|
let cedarlingPromise: Promise<CedarlingInstance> | null = null;
|
|
18
|
+
let wasmInitPromise: Promise<void> | null = null;
|
|
15
19
|
|
|
16
20
|
export async function getCedarling(config: CedarlingConfig): Promise<CedarlingInstance> {
|
|
17
21
|
if (!cedarlingPromise) {
|
|
@@ -20,8 +24,19 @@ export async function getCedarling(config: CedarlingConfig): Promise<CedarlingIn
|
|
|
20
24
|
return cedarlingPromise;
|
|
21
25
|
}
|
|
22
26
|
|
|
27
|
+
async function ensureWasmInitialized(): Promise<void> {
|
|
28
|
+
if (!wasmInitPromise) {
|
|
29
|
+
wasmInitPromise = (async () => {
|
|
30
|
+
const wasmPath = require.resolve("@janssenproject/cedarling_wasm/cedarling_wasm_bg.wasm");
|
|
31
|
+
const wasmBytes = await readFile(wasmPath);
|
|
32
|
+
await initWasm(wasmBytes);
|
|
33
|
+
})();
|
|
34
|
+
}
|
|
35
|
+
return wasmInitPromise;
|
|
36
|
+
}
|
|
37
|
+
|
|
23
38
|
async function createCedarling(config: CedarlingConfig): Promise<CedarlingInstance> {
|
|
24
|
-
await
|
|
39
|
+
await ensureWasmInitialized();
|
|
25
40
|
const instance = await init(config);
|
|
26
41
|
return instance as CedarlingInstance;
|
|
27
42
|
}
|
package/src/config.ts
CHANGED
package/src/guard.ts
CHANGED
|
@@ -9,7 +9,6 @@ import { getCedarling } from "./cedarling.js";
|
|
|
9
9
|
import { appendAuditEntry, createAuditEntryId } from "./audit.js";
|
|
10
10
|
import { listGrants, findActiveGrant } from "./grants.js";
|
|
11
11
|
import { getModeOverride } from "./mode.js";
|
|
12
|
-
import { isToolDenied, listDeniedTools } from "./denies.js";
|
|
13
12
|
import { buildDefaultPolicyStore } from "./policy.js";
|
|
14
13
|
import { ensurePolicyStore } from "./policy-store.js";
|
|
15
14
|
import type { AuditEntry, ClawClampConfig, ClawClampMode, RiskLevel } from "./types.js";
|
|
@@ -173,13 +172,9 @@ export class ClawClampService {
|
|
|
173
172
|
const auditId = createAuditEntryId();
|
|
174
173
|
const mode = await this.getEffectiveMode();
|
|
175
174
|
const grant = await this.resolveGrant(toolName);
|
|
176
|
-
const deniedTools = await listDeniedTools(this.stateDir);
|
|
177
|
-
const explicitlyDenied = isToolDenied(deniedTools, toolName);
|
|
178
175
|
|
|
179
176
|
let cedarDecision: CedarEvaluation;
|
|
180
|
-
if (
|
|
181
|
-
cedarDecision = { decision: "deny", reason: "Denied by operator" };
|
|
182
|
-
} else if (this.config.enabled) {
|
|
177
|
+
if (this.config.enabled) {
|
|
183
178
|
const cedarling = await this.getCedarlingInstance();
|
|
184
179
|
cedarDecision = await evaluateCedar({
|
|
185
180
|
cedarling,
|
package/src/http.ts
CHANGED
|
@@ -4,7 +4,6 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
4
4
|
import { listGrants, createGrant, revokeGrant } from "./grants.js";
|
|
5
5
|
import { readAuditEntries } from "./audit.js";
|
|
6
6
|
import { getModeOverride, setModeOverride } from "./mode.js";
|
|
7
|
-
import { addDeniedTool, listDeniedTools, removeDeniedTool } from "./denies.js";
|
|
8
7
|
import { createPolicy, deletePolicy, listPolicies, updatePolicy } from "./policy-store.js";
|
|
9
8
|
import type { ClawClampConfig, ClawClampMode } from "./types.js";
|
|
10
9
|
|
|
@@ -290,40 +289,6 @@ export function createClawClampHttpHandler(params: {
|
|
|
290
289
|
return true;
|
|
291
290
|
}
|
|
292
291
|
|
|
293
|
-
if (apiPath === "denies" && req.method === "GET") {
|
|
294
|
-
const tools = await listDeniedTools(params.stateDir);
|
|
295
|
-
sendJson(res, 200, { tools });
|
|
296
|
-
return true;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
if (apiPath === "denies" && req.method === "POST") {
|
|
300
|
-
try {
|
|
301
|
-
const body = (await readJsonBody(req)) as Record<string, unknown>;
|
|
302
|
-
const toolName = typeof body?.toolName === "string" ? body.toolName.trim() : "";
|
|
303
|
-
if (!toolName) {
|
|
304
|
-
sendJson(res, 400, { error: "toolName is required" });
|
|
305
|
-
return true;
|
|
306
|
-
}
|
|
307
|
-
const tools = await addDeniedTool(params.stateDir, toolName);
|
|
308
|
-
sendJson(res, 200, { tools });
|
|
309
|
-
return true;
|
|
310
|
-
} catch (error) {
|
|
311
|
-
sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
312
|
-
return true;
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
if (apiPath.startsWith("denies/") && req.method === "DELETE") {
|
|
317
|
-
const toolName = apiPath.slice("denies/".length);
|
|
318
|
-
if (!toolName) {
|
|
319
|
-
sendJson(res, 400, { error: "toolName is required" });
|
|
320
|
-
return true;
|
|
321
|
-
}
|
|
322
|
-
const tools = await removeDeniedTool(params.stateDir, toolName);
|
|
323
|
-
sendJson(res, 200, { tools });
|
|
324
|
-
return true;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
292
|
if (apiPath === "policies" && req.method === "GET") {
|
|
328
293
|
if (params.config.policyStoreUri) {
|
|
329
294
|
sendJson(res, 200, { readOnly: true, policies: [], schema: "" });
|
package/src/policy.ts
CHANGED
|
@@ -24,11 +24,7 @@ action "Invoke" appliesTo {
|
|
|
24
24
|
};
|
|
25
25
|
`;
|
|
26
26
|
|
|
27
|
-
const DEFAULT_POLICIES = [
|
|
28
|
-
`permit(principal, action, resource)
|
|
29
|
-
when {
|
|
30
|
-
action == Action::"Invoke" && resource.risk == "low"
|
|
31
|
-
};`,
|
|
27
|
+
const DEFAULT_POLICIES: string[] = [
|
|
32
28
|
`permit(principal, action, resource)
|
|
33
29
|
when {
|
|
34
30
|
action == Action::"Invoke" && context.grant_active == true
|
package/src/denies.ts
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk";
|
|
4
|
-
import { withStateFileLock } from "./storage.js";
|
|
5
|
-
|
|
6
|
-
const DENIES_FILE = "denies.json";
|
|
7
|
-
|
|
8
|
-
type DenyState = {
|
|
9
|
-
tools: string[];
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
const EMPTY_STATE: DenyState = { tools: [] };
|
|
13
|
-
|
|
14
|
-
function resolveDenyPath(stateDir: string): string {
|
|
15
|
-
return path.join(stateDir, "clawclamp", DENIES_FILE);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
async function readDenyState(stateDir: string): Promise<DenyState> {
|
|
19
|
-
const filePath = resolveDenyPath(stateDir);
|
|
20
|
-
const { value } = await readJsonFileWithFallback(filePath, EMPTY_STATE);
|
|
21
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
22
|
-
return { tools: [] };
|
|
23
|
-
}
|
|
24
|
-
const tools = Array.isArray((value as DenyState).tools) ? (value as DenyState).tools : [];
|
|
25
|
-
return { tools };
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
async function writeDenyState(stateDir: string, state: DenyState): Promise<void> {
|
|
29
|
-
const filePath = resolveDenyPath(stateDir);
|
|
30
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
31
|
-
await writeJsonFileAtomically(filePath, state);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function normalizeToolName(toolName: string): string {
|
|
35
|
-
return toolName.trim();
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export async function listDeniedTools(stateDir: string): Promise<string[]> {
|
|
39
|
-
return withStateFileLock(stateDir, "denies", async () => {
|
|
40
|
-
const state = await readDenyState(stateDir);
|
|
41
|
-
return state.tools;
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export async function addDeniedTool(stateDir: string, toolName: string): Promise<string[]> {
|
|
46
|
-
return withStateFileLock(stateDir, "denies", async () => {
|
|
47
|
-
const state = await readDenyState(stateDir);
|
|
48
|
-
const normalized = normalizeToolName(toolName);
|
|
49
|
-
if (!normalized) {
|
|
50
|
-
return state.tools;
|
|
51
|
-
}
|
|
52
|
-
if (!state.tools.includes(normalized)) {
|
|
53
|
-
state.tools.push(normalized);
|
|
54
|
-
await writeDenyState(stateDir, state);
|
|
55
|
-
}
|
|
56
|
-
return state.tools;
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export async function removeDeniedTool(stateDir: string, toolName: string): Promise<string[]> {
|
|
61
|
-
return withStateFileLock(stateDir, "denies", async () => {
|
|
62
|
-
const state = await readDenyState(stateDir);
|
|
63
|
-
const normalized = normalizeToolName(toolName);
|
|
64
|
-
const next = state.tools.filter((tool) => tool !== normalized);
|
|
65
|
-
if (next.length !== state.tools.length) {
|
|
66
|
-
await writeDenyState(stateDir, { tools: next });
|
|
67
|
-
return next;
|
|
68
|
-
}
|
|
69
|
-
return state.tools;
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function isToolDenied(deniedTools: string[], toolName: string): boolean {
|
|
74
|
-
return deniedTools.includes(toolName) || deniedTools.includes("*");
|
|
75
|
-
}
|