clawclamp 0.1.6 → 0.1.8
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 +14 -1
- package/assets/app.js +164 -1
- package/assets/index.html +30 -0
- package/assets/styles.css +179 -59
- package/index.ts +7 -1
- package/package.json +1 -1
- package/src/config.ts +6 -1
- package/src/guard.ts +13 -5
- package/src/http.ts +179 -0
- package/src/policy-store.ts +133 -0
- package/src/policy.ts +1 -5
- package/src/types.ts +1 -0
package/README.md
CHANGED
|
@@ -20,10 +20,12 @@ 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
|
|
27
|
+
# 可选:UI 访问令牌(非 loopback 时可通过 ?token= 或 X-OpenClaw-Token 访问)
|
|
28
|
+
# uiToken: "your-ui-token"
|
|
27
29
|
risk:
|
|
28
30
|
default: high
|
|
29
31
|
overrides:
|
|
@@ -43,3 +45,14 @@ plugins:
|
|
|
43
45
|
## UI
|
|
44
46
|
|
|
45
47
|
Open the gateway path `/plugins/clawclamp` to view audit logs, toggle gray mode, and create short-term grants.
|
|
48
|
+
|
|
49
|
+
UI access rules:
|
|
50
|
+
|
|
51
|
+
- Loopback (127.0.0.1 / ::1) is allowed without a token.
|
|
52
|
+
- Non-loopback requires a token via `?token=` or `X-OpenClaw-Token` / `Authorization: Bearer`.
|
|
53
|
+
|
|
54
|
+
Policy management:
|
|
55
|
+
|
|
56
|
+
- The UI includes a Cedar policy panel for CRUD.
|
|
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
|
@@ -14,6 +14,17 @@ const grantNoteInput = qs("grant-note");
|
|
|
14
14
|
const grantHint = qs("grant-hint");
|
|
15
15
|
const grantsEl = qs("grants");
|
|
16
16
|
const auditEl = qs("audit");
|
|
17
|
+
const policyListEl = qs("policy-list");
|
|
18
|
+
const policyIdInput = qs("policy-id");
|
|
19
|
+
const policyContentInput = qs("policy-content");
|
|
20
|
+
const policyCreateBtn = qs("policy-create");
|
|
21
|
+
const policyUpdateBtn = qs("policy-update");
|
|
22
|
+
const policyDeleteBtn = qs("policy-delete");
|
|
23
|
+
const policyStatusEl = qs("policy-status");
|
|
24
|
+
const policySchemaEl = qs("policy-schema");
|
|
25
|
+
let policyReadOnly = false;
|
|
26
|
+
let policies = [];
|
|
27
|
+
let deniedTools = new Set();
|
|
17
28
|
|
|
18
29
|
async function fetchJson(path, opts = {}) {
|
|
19
30
|
const res = await fetch(path, {
|
|
@@ -98,7 +109,8 @@ function renderAudit(entries) {
|
|
|
98
109
|
|
|
99
110
|
const header = document.createElement("div");
|
|
100
111
|
header.className = "table-row header";
|
|
101
|
-
header.innerHTML =
|
|
112
|
+
header.innerHTML =
|
|
113
|
+
"<div>时间</div><div>工具</div><div>决策</div><div>风险</div><div>说明</div><div>操作</div>";
|
|
102
114
|
|
|
103
115
|
auditEl.innerHTML = "";
|
|
104
116
|
auditEl.appendChild(header);
|
|
@@ -107,17 +119,84 @@ function renderAudit(entries) {
|
|
|
107
119
|
const row = document.createElement("div");
|
|
108
120
|
row.className = "table-row";
|
|
109
121
|
const badgeClass = decisionBadge(entry.decision);
|
|
122
|
+
const canAllow = entry.decision === "deny" || entry.decision === "allow_grayed";
|
|
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);
|
|
110
134
|
row.innerHTML = `
|
|
111
135
|
<div>${formatTime(entry.timestamp)}</div>
|
|
112
136
|
<div class="mono">${entry.toolName}</div>
|
|
113
137
|
<div class="badge ${badgeClass}">${decisionLabel(entry.decision)}</div>
|
|
114
138
|
<div>${riskLabel(entry.risk)}</div>
|
|
115
139
|
<div>${entry.reason || entry.error || ""}</div>
|
|
140
|
+
<div class="actions">
|
|
141
|
+
${canAllow ? "<button class=\"btn mini\" data-action=\"allow\">一键允许</button>" : ""}
|
|
142
|
+
${canDeny ? "<button class=\"btn mini warn\" data-action=\"deny\">一键拒绝</button>" : ""}
|
|
143
|
+
</div>
|
|
144
|
+
<div class="audit-detail">
|
|
145
|
+
<div class="audit-meta">${meta.join(" · ")}</div>
|
|
146
|
+
${paramsText ? `<pre>${paramsText}</pre>` : ""}
|
|
147
|
+
</div>
|
|
116
148
|
`;
|
|
149
|
+
const allowBtn = row.querySelector("button[data-action=\"allow\"]");
|
|
150
|
+
if (allowBtn) {
|
|
151
|
+
allowBtn.addEventListener("click", async () => {
|
|
152
|
+
await applyPolicyChange("permit", entry.toolName);
|
|
153
|
+
await refreshAll();
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
const denyBtn = row.querySelector("button[data-action=\"deny\"]");
|
|
157
|
+
if (denyBtn) {
|
|
158
|
+
denyBtn.addEventListener("click", async () => {
|
|
159
|
+
await applyPolicyChange("forbid", entry.toolName);
|
|
160
|
+
await refreshAll();
|
|
161
|
+
});
|
|
162
|
+
}
|
|
117
163
|
auditEl.appendChild(row);
|
|
118
164
|
});
|
|
119
165
|
}
|
|
120
166
|
|
|
167
|
+
function renderPolicyList(list) {
|
|
168
|
+
policies = list;
|
|
169
|
+
if (!list.length) {
|
|
170
|
+
policyListEl.innerHTML = "<div class=\"note\">暂无策略。</div>";
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
policyListEl.innerHTML = "";
|
|
174
|
+
list.forEach((policy) => {
|
|
175
|
+
const item = document.createElement("button");
|
|
176
|
+
item.className = "policy-item";
|
|
177
|
+
item.textContent = policy.id;
|
|
178
|
+
item.addEventListener("click", () => {
|
|
179
|
+
policyIdInput.value = policy.id;
|
|
180
|
+
policyContentInput.value = policy.content || "";
|
|
181
|
+
policyStatusEl.textContent = "";
|
|
182
|
+
});
|
|
183
|
+
policyListEl.appendChild(item);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function refreshPolicies() {
|
|
188
|
+
const result = await fetchJson(`${API_BASE}/policies`);
|
|
189
|
+
policyReadOnly = result.readOnly === true;
|
|
190
|
+
renderPolicyList(result.policies || []);
|
|
191
|
+
policySchemaEl.textContent = result.schema || "";
|
|
192
|
+
policyCreateBtn.disabled = policyReadOnly;
|
|
193
|
+
policyUpdateBtn.disabled = policyReadOnly;
|
|
194
|
+
policyDeleteBtn.disabled = policyReadOnly;
|
|
195
|
+
if (policyReadOnly) {
|
|
196
|
+
policyStatusEl.textContent = "policyStoreUri 模式下为只读。";
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
121
200
|
async function refreshAll() {
|
|
122
201
|
const state = await fetchJson(`${API_BASE}/state`);
|
|
123
202
|
renderMode(state);
|
|
@@ -125,6 +204,7 @@ async function refreshAll() {
|
|
|
125
204
|
renderGrants(grants.grants || []);
|
|
126
205
|
const logs = await fetchJson(`${API_BASE}/logs`);
|
|
127
206
|
renderAudit(logs.entries || []);
|
|
207
|
+
await refreshPolicies();
|
|
128
208
|
}
|
|
129
209
|
|
|
130
210
|
refreshBtn.addEventListener("click", () => {
|
|
@@ -164,3 +244,86 @@ grantForm.addEventListener("submit", async (event) => {
|
|
|
164
244
|
|
|
165
245
|
refreshAll();
|
|
166
246
|
setInterval(refreshAll, 10_000);
|
|
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
|
+
|
|
281
|
+
policyCreateBtn.addEventListener("click", async () => {
|
|
282
|
+
const id = policyIdInput.value.trim();
|
|
283
|
+
const content = policyContentInput.value.trim();
|
|
284
|
+
if (!content) {
|
|
285
|
+
policyStatusEl.textContent = "请输入 Policy 内容。";
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const body = { content, ...(id ? { id } : {}) };
|
|
289
|
+
await fetchJson(`${API_BASE}/policies`, {
|
|
290
|
+
method: "POST",
|
|
291
|
+
body: JSON.stringify(body),
|
|
292
|
+
});
|
|
293
|
+
policyStatusEl.textContent = "已新增策略。";
|
|
294
|
+
await refreshPolicies();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
policyUpdateBtn.addEventListener("click", async () => {
|
|
298
|
+
const id = policyIdInput.value.trim();
|
|
299
|
+
const content = policyContentInput.value.trim();
|
|
300
|
+
if (!id) {
|
|
301
|
+
policyStatusEl.textContent = "请选择或填写 Policy ID。";
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (!content) {
|
|
305
|
+
policyStatusEl.textContent = "请输入 Policy 内容。";
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
await fetchJson(`${API_BASE}/policies/${encodeURIComponent(id)}`, {
|
|
309
|
+
method: "PUT",
|
|
310
|
+
body: JSON.stringify({ content }),
|
|
311
|
+
});
|
|
312
|
+
policyStatusEl.textContent = "已保存策略。";
|
|
313
|
+
await refreshPolicies();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
policyDeleteBtn.addEventListener("click", async () => {
|
|
317
|
+
const id = policyIdInput.value.trim();
|
|
318
|
+
if (!id) {
|
|
319
|
+
policyStatusEl.textContent = "请选择或填写 Policy ID。";
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
await fetchJson(`${API_BASE}/policies/${encodeURIComponent(id)}`, {
|
|
323
|
+
method: "DELETE",
|
|
324
|
+
});
|
|
325
|
+
policyStatusEl.textContent = "已删除策略。";
|
|
326
|
+
policyIdInput.value = "";
|
|
327
|
+
policyContentInput.value = "";
|
|
328
|
+
await refreshPolicies();
|
|
329
|
+
});
|
package/assets/index.html
CHANGED
|
@@ -68,6 +68,36 @@
|
|
|
68
68
|
<div class="note">最近的工具调用记录(允许、拒绝、灰度放行)。</div>
|
|
69
69
|
<div class="table" id="audit"></div>
|
|
70
70
|
</section>
|
|
71
|
+
|
|
72
|
+
<section class="card">
|
|
73
|
+
<h2>Cedar 策略</h2>
|
|
74
|
+
<div class="note">查看与维护当前策略集(policyStoreUri 配置时为只读)。</div>
|
|
75
|
+
<div class="policy-grid">
|
|
76
|
+
<div class="policy-list" id="policy-list"></div>
|
|
77
|
+
<div class="policy-editor">
|
|
78
|
+
<div class="policy-form">
|
|
79
|
+
<label>
|
|
80
|
+
Policy ID
|
|
81
|
+
<input id="policy-id" placeholder="policy-id" />
|
|
82
|
+
</label>
|
|
83
|
+
<label>
|
|
84
|
+
Policy 内容
|
|
85
|
+
<textarea id="policy-content" rows="8" placeholder="permit(principal, action, resource) when { ... }"></textarea>
|
|
86
|
+
</label>
|
|
87
|
+
<div class="policy-actions">
|
|
88
|
+
<button id="policy-create" class="btn primary">新增</button>
|
|
89
|
+
<button id="policy-update" class="btn">保存</button>
|
|
90
|
+
<button id="policy-delete" class="btn warn">删除</button>
|
|
91
|
+
</div>
|
|
92
|
+
<div id="policy-status" class="note"></div>
|
|
93
|
+
</div>
|
|
94
|
+
<div class="policy-schema">
|
|
95
|
+
<div class="label">Schema</div>
|
|
96
|
+
<pre id="policy-schema"></pre>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</section>
|
|
71
101
|
</div>
|
|
72
102
|
|
|
73
103
|
<script type="module" src="/plugins/clawclamp/assets/app.js"></script>
|
package/assets/styles.css
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
:root {
|
|
2
2
|
color-scheme: light;
|
|
3
|
-
--bg: #
|
|
3
|
+
--bg: #f2f3f5;
|
|
4
4
|
--panel: #ffffff;
|
|
5
|
-
--ink: #
|
|
6
|
-
--muted: #
|
|
7
|
-
--accent: #
|
|
8
|
-
--accent-2: #
|
|
9
|
-
--warn: #
|
|
10
|
-
--border: #
|
|
11
|
-
--shadow: 0
|
|
12
|
-
--radius:
|
|
13
|
-
--mono: "IBM Plex Mono", "SFMono-Regular", "Menlo", "Consolas", monospace;
|
|
14
|
-
--sans: "Space Grotesk", "
|
|
5
|
+
--ink: #0f1115;
|
|
6
|
+
--muted: #5f6774;
|
|
7
|
+
--accent: #00a3ff;
|
|
8
|
+
--accent-2: #7c8cff;
|
|
9
|
+
--warn: #ff5b4d;
|
|
10
|
+
--border: #d7dbe2;
|
|
11
|
+
--shadow: 0 10px 30px rgba(4, 8, 16, 0.08);
|
|
12
|
+
--radius: 10px;
|
|
13
|
+
--mono: "JetBrains Mono", "IBM Plex Mono", "SFMono-Regular", "Menlo", "Consolas", monospace;
|
|
14
|
+
--sans: "Space Grotesk", "IBM Plex Sans", "Segoe UI", sans-serif;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
* {
|
|
@@ -20,28 +20,28 @@
|
|
|
20
20
|
|
|
21
21
|
body {
|
|
22
22
|
margin: 0;
|
|
23
|
-
font-family: var(--
|
|
24
|
-
background: radial-gradient(circle at top, #
|
|
23
|
+
font-family: var(--mono);
|
|
24
|
+
background: radial-gradient(circle at top, #ffffff 0%, var(--bg) 45%, #e7eaef 100%);
|
|
25
25
|
color: var(--ink);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
.page {
|
|
29
|
-
max-width:
|
|
29
|
+
max-width: 1120px;
|
|
30
30
|
margin: 0 auto;
|
|
31
|
-
padding:
|
|
31
|
+
padding: 20px 18px 32px;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
.header {
|
|
35
35
|
display: flex;
|
|
36
36
|
align-items: flex-start;
|
|
37
37
|
justify-content: space-between;
|
|
38
|
-
gap:
|
|
39
|
-
padding:
|
|
38
|
+
gap: 16px;
|
|
39
|
+
padding: 16px 18px;
|
|
40
40
|
background: var(--panel);
|
|
41
41
|
border-radius: var(--radius);
|
|
42
42
|
box-shadow: var(--shadow);
|
|
43
43
|
border: 1px solid var(--border);
|
|
44
|
-
margin-bottom:
|
|
44
|
+
margin-bottom: 16px;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
.eyebrow {
|
|
@@ -53,8 +53,9 @@ body {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
h1 {
|
|
56
|
-
margin: 0 0
|
|
57
|
-
font-size:
|
|
56
|
+
margin: 0 0 4px;
|
|
57
|
+
font-size: 1.6rem;
|
|
58
|
+
letter-spacing: 0.02em;
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
.subhead {
|
|
@@ -70,35 +71,36 @@ h1 {
|
|
|
70
71
|
|
|
71
72
|
.grid {
|
|
72
73
|
display: grid;
|
|
73
|
-
grid-template-columns: repeat(auto-fit, minmax(
|
|
74
|
-
gap:
|
|
75
|
-
margin-bottom:
|
|
74
|
+
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
|
75
|
+
gap: 12px;
|
|
76
|
+
margin-bottom: 16px;
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
.card {
|
|
79
80
|
background: var(--panel);
|
|
80
81
|
border-radius: var(--radius);
|
|
81
|
-
padding:
|
|
82
|
+
padding: 14px;
|
|
82
83
|
border: 1px solid var(--border);
|
|
83
84
|
box-shadow: var(--shadow);
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
h2 {
|
|
87
|
-
margin: 0 0
|
|
88
|
-
font-size:
|
|
88
|
+
margin: 0 0 8px;
|
|
89
|
+
font-size: 1rem;
|
|
90
|
+
letter-spacing: 0.02em;
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
.mode-row {
|
|
92
94
|
display: flex;
|
|
93
95
|
align-items: center;
|
|
94
96
|
justify-content: space-between;
|
|
95
|
-
gap:
|
|
97
|
+
gap: 12px;
|
|
96
98
|
}
|
|
97
99
|
|
|
98
100
|
.mode-actions {
|
|
99
101
|
display: flex;
|
|
100
102
|
flex-direction: column;
|
|
101
|
-
gap:
|
|
103
|
+
gap: 6px;
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
.label {
|
|
@@ -107,7 +109,7 @@ h2 {
|
|
|
107
109
|
}
|
|
108
110
|
|
|
109
111
|
.value {
|
|
110
|
-
font-size: 1.
|
|
112
|
+
font-size: 1.1rem;
|
|
111
113
|
font-weight: 600;
|
|
112
114
|
}
|
|
113
115
|
|
|
@@ -118,12 +120,14 @@ h2 {
|
|
|
118
120
|
}
|
|
119
121
|
|
|
120
122
|
.btn {
|
|
121
|
-
padding:
|
|
122
|
-
border-radius:
|
|
123
|
+
padding: 8px 12px;
|
|
124
|
+
border-radius: 8px;
|
|
123
125
|
border: 1px solid var(--border);
|
|
124
126
|
background: #fff;
|
|
125
127
|
cursor: pointer;
|
|
126
128
|
font-weight: 600;
|
|
129
|
+
font-family: var(--mono);
|
|
130
|
+
font-size: 0.82rem;
|
|
127
131
|
}
|
|
128
132
|
|
|
129
133
|
.btn.primary {
|
|
@@ -138,6 +142,12 @@ h2 {
|
|
|
138
142
|
border-color: var(--warn);
|
|
139
143
|
}
|
|
140
144
|
|
|
145
|
+
.btn.mini {
|
|
146
|
+
padding: 6px 8px;
|
|
147
|
+
border-radius: 6px;
|
|
148
|
+
font-size: 0.75rem;
|
|
149
|
+
}
|
|
150
|
+
|
|
141
151
|
.btn.ghost {
|
|
142
152
|
background: transparent;
|
|
143
153
|
}
|
|
@@ -163,10 +173,10 @@ h2 {
|
|
|
163
173
|
|
|
164
174
|
input {
|
|
165
175
|
border: 1px solid var(--border);
|
|
166
|
-
border-radius:
|
|
167
|
-
padding: 10px
|
|
168
|
-
font-size: 0.
|
|
169
|
-
font-family: var(--
|
|
176
|
+
border-radius: 8px;
|
|
177
|
+
padding: 8px 10px;
|
|
178
|
+
font-size: 0.85rem;
|
|
179
|
+
font-family: var(--mono);
|
|
170
180
|
}
|
|
171
181
|
|
|
172
182
|
.list {
|
|
@@ -178,11 +188,11 @@ input {
|
|
|
178
188
|
display: flex;
|
|
179
189
|
justify-content: space-between;
|
|
180
190
|
align-items: center;
|
|
181
|
-
padding:
|
|
182
|
-
border-radius:
|
|
191
|
+
padding: 10px;
|
|
192
|
+
border-radius: 8px;
|
|
183
193
|
border: 1px solid var(--border);
|
|
184
|
-
background: #
|
|
185
|
-
font-size: 0.
|
|
194
|
+
background: #f6f7f9;
|
|
195
|
+
font-size: 0.8rem;
|
|
186
196
|
}
|
|
187
197
|
|
|
188
198
|
.list-item strong {
|
|
@@ -191,24 +201,24 @@ input {
|
|
|
191
201
|
|
|
192
202
|
.table {
|
|
193
203
|
display: grid;
|
|
194
|
-
gap:
|
|
195
|
-
margin-top:
|
|
204
|
+
gap: 6px;
|
|
205
|
+
margin-top: 10px;
|
|
196
206
|
}
|
|
197
207
|
|
|
198
208
|
.table-row {
|
|
199
209
|
display: grid;
|
|
200
|
-
grid-template-columns:
|
|
201
|
-
gap:
|
|
210
|
+
grid-template-columns: 110px 110px 90px 70px 1fr 120px;
|
|
211
|
+
gap: 10px;
|
|
202
212
|
align-items: center;
|
|
203
|
-
padding: 10px
|
|
204
|
-
border-radius:
|
|
213
|
+
padding: 8px 10px;
|
|
214
|
+
border-radius: 8px;
|
|
205
215
|
border: 1px solid var(--border);
|
|
206
|
-
background: #
|
|
207
|
-
font-size: 0.
|
|
216
|
+
background: #fdfdfd;
|
|
217
|
+
font-size: 0.78rem;
|
|
208
218
|
}
|
|
209
219
|
|
|
210
220
|
.table-row.header {
|
|
211
|
-
background: #
|
|
221
|
+
background: #eef2f7;
|
|
212
222
|
font-weight: 700;
|
|
213
223
|
}
|
|
214
224
|
|
|
@@ -216,37 +226,147 @@ input {
|
|
|
216
226
|
font-family: var(--mono);
|
|
217
227
|
}
|
|
218
228
|
|
|
229
|
+
.actions {
|
|
230
|
+
display: flex;
|
|
231
|
+
gap: 6px;
|
|
232
|
+
flex-wrap: wrap;
|
|
233
|
+
}
|
|
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
|
+
|
|
255
|
+
.policy-grid {
|
|
256
|
+
display: grid;
|
|
257
|
+
grid-template-columns: 220px 1fr;
|
|
258
|
+
gap: 12px;
|
|
259
|
+
margin-top: 10px;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.policy-list {
|
|
263
|
+
display: grid;
|
|
264
|
+
gap: 6px;
|
|
265
|
+
align-content: start;
|
|
266
|
+
max-height: 360px;
|
|
267
|
+
overflow: auto;
|
|
268
|
+
padding-right: 4px;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.policy-item {
|
|
272
|
+
padding: 6px 8px;
|
|
273
|
+
border-radius: 6px;
|
|
274
|
+
border: 1px solid var(--border);
|
|
275
|
+
background: #f7f8fb;
|
|
276
|
+
font-family: var(--mono);
|
|
277
|
+
font-size: 0.75rem;
|
|
278
|
+
text-align: left;
|
|
279
|
+
cursor: pointer;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.policy-editor {
|
|
283
|
+
display: grid;
|
|
284
|
+
grid-template-columns: 1fr 1fr;
|
|
285
|
+
gap: 12px;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.policy-form {
|
|
289
|
+
display: grid;
|
|
290
|
+
gap: 8px;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.policy-form label {
|
|
294
|
+
display: grid;
|
|
295
|
+
gap: 6px;
|
|
296
|
+
font-size: 0.8rem;
|
|
297
|
+
color: var(--muted);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.policy-form textarea {
|
|
301
|
+
border: 1px solid var(--border);
|
|
302
|
+
border-radius: 8px;
|
|
303
|
+
padding: 8px 10px;
|
|
304
|
+
font-size: 0.8rem;
|
|
305
|
+
font-family: var(--mono);
|
|
306
|
+
min-height: 160px;
|
|
307
|
+
resize: vertical;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.policy-actions {
|
|
311
|
+
display: flex;
|
|
312
|
+
gap: 6px;
|
|
313
|
+
flex-wrap: wrap;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.policy-schema pre {
|
|
317
|
+
margin: 0;
|
|
318
|
+
padding: 10px;
|
|
319
|
+
border: 1px solid var(--border);
|
|
320
|
+
border-radius: 8px;
|
|
321
|
+
background: #f3f5f9;
|
|
322
|
+
font-size: 0.75rem;
|
|
323
|
+
line-height: 1.4;
|
|
324
|
+
max-height: 260px;
|
|
325
|
+
overflow: auto;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
@media (max-width: 900px) {
|
|
329
|
+
.policy-grid {
|
|
330
|
+
grid-template-columns: 1fr;
|
|
331
|
+
}
|
|
332
|
+
.policy-editor {
|
|
333
|
+
grid-template-columns: 1fr;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
219
337
|
.badge {
|
|
220
|
-
padding:
|
|
338
|
+
padding: 3px 6px;
|
|
221
339
|
border-radius: 999px;
|
|
222
|
-
font-size: 0.
|
|
340
|
+
font-size: 0.68rem;
|
|
223
341
|
text-transform: uppercase;
|
|
224
342
|
font-weight: 700;
|
|
225
343
|
justify-self: start;
|
|
226
344
|
}
|
|
227
345
|
|
|
228
346
|
.badge.allow {
|
|
229
|
-
background: rgba(
|
|
230
|
-
color: #
|
|
347
|
+
background: rgba(0, 163, 255, 0.15);
|
|
348
|
+
color: #0086cc;
|
|
231
349
|
}
|
|
232
350
|
|
|
233
351
|
.badge.deny {
|
|
234
|
-
background: rgba(
|
|
235
|
-
color: #
|
|
352
|
+
background: rgba(255, 91, 77, 0.15);
|
|
353
|
+
color: #d9392c;
|
|
236
354
|
}
|
|
237
355
|
|
|
238
356
|
.badge.gray {
|
|
239
|
-
background: rgba(
|
|
240
|
-
color: #
|
|
357
|
+
background: rgba(124, 140, 255, 0.2);
|
|
358
|
+
color: #4f5bff;
|
|
241
359
|
}
|
|
242
360
|
|
|
243
361
|
@media (max-width: 900px) {
|
|
244
362
|
.table-row {
|
|
245
|
-
grid-template-columns:
|
|
246
|
-
grid-template-rows: auto auto;
|
|
363
|
+
grid-template-columns: 100px 1fr;
|
|
364
|
+
grid-template-rows: auto auto auto;
|
|
247
365
|
}
|
|
366
|
+
.table-row > :nth-child(3),
|
|
248
367
|
.table-row > :nth-child(4),
|
|
249
|
-
.table-row > :nth-child(5)
|
|
368
|
+
.table-row > :nth-child(5),
|
|
369
|
+
.table-row > :nth-child(6) {
|
|
250
370
|
grid-column: 1 / -1;
|
|
251
371
|
}
|
|
252
372
|
}
|
package/index.ts
CHANGED
|
@@ -23,14 +23,20 @@ const plugin = {
|
|
|
23
23
|
api.on("after_tool_call", async (event, ctx) => service.handleAfterToolCall(event, ctx));
|
|
24
24
|
|
|
25
25
|
const assetsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "assets");
|
|
26
|
+
const gatewayToken =
|
|
27
|
+
typeof api.config.gateway?.auth?.token === "string" ? api.config.gateway.auth.token : undefined;
|
|
26
28
|
api.registerHttpRoute({
|
|
27
29
|
path: "/plugins/clawclamp",
|
|
28
|
-
auth: "
|
|
30
|
+
auth: "plugin",
|
|
29
31
|
match: "prefix",
|
|
30
32
|
handler: createClawClampHttpHandler({
|
|
31
33
|
stateDir,
|
|
32
34
|
config,
|
|
33
35
|
assetsDir,
|
|
36
|
+
gatewayToken,
|
|
37
|
+
onPolicyUpdate: async () => {
|
|
38
|
+
await service.resetCedarling();
|
|
39
|
+
},
|
|
34
40
|
}),
|
|
35
41
|
});
|
|
36
42
|
},
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -31,7 +31,7 @@ const DEFAULT_RISK_OVERRIDES: Record<string, RiskLevel> = {
|
|
|
31
31
|
|
|
32
32
|
export const DEFAULT_CLAWCLAMP_CONFIG: ClawClampConfig = {
|
|
33
33
|
enabled: true,
|
|
34
|
-
mode: "
|
|
34
|
+
mode: "gray",
|
|
35
35
|
principalId: "openclaw",
|
|
36
36
|
policyFailOpen: false,
|
|
37
37
|
risk: {
|
|
@@ -64,6 +64,7 @@ const CEDAR_GUARD_CONFIG_JSON_SCHEMA = {
|
|
|
64
64
|
principalId: { type: "string", default: DEFAULT_CLAWCLAMP_CONFIG.principalId },
|
|
65
65
|
policyStoreUri: { type: "string" },
|
|
66
66
|
policyStoreLocal: { type: "string" },
|
|
67
|
+
uiToken: { type: "string" },
|
|
67
68
|
policyFailOpen: { type: "boolean", default: DEFAULT_CLAWCLAMP_CONFIG.policyFailOpen },
|
|
68
69
|
risk: {
|
|
69
70
|
type: "object",
|
|
@@ -188,6 +189,10 @@ export function resolveClawClampConfig(input: unknown): ClawClampConfig {
|
|
|
188
189
|
typeof raw.policyStoreLocal === "string" && raw.policyStoreLocal.trim()
|
|
189
190
|
? raw.policyStoreLocal.trim()
|
|
190
191
|
: undefined,
|
|
192
|
+
uiToken:
|
|
193
|
+
typeof raw.uiToken === "string" && raw.uiToken.trim()
|
|
194
|
+
? raw.uiToken.trim()
|
|
195
|
+
: undefined,
|
|
191
196
|
policyFailOpen:
|
|
192
197
|
typeof raw.policyFailOpen === "boolean"
|
|
193
198
|
? raw.policyFailOpen
|
package/src/guard.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { appendAuditEntry, createAuditEntryId } from "./audit.js";
|
|
|
10
10
|
import { listGrants, findActiveGrant } from "./grants.js";
|
|
11
11
|
import { getModeOverride } from "./mode.js";
|
|
12
12
|
import { buildDefaultPolicyStore } from "./policy.js";
|
|
13
|
+
import { ensurePolicyStore } from "./policy-store.js";
|
|
13
14
|
import type { AuditEntry, ClawClampConfig, ClawClampMode, RiskLevel } from "./types.js";
|
|
14
15
|
|
|
15
16
|
type CedarDecision = "allow" | "deny" | "error";
|
|
@@ -58,9 +59,12 @@ function summarizeParams(
|
|
|
58
59
|
return summary;
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
function buildCedarlingConfig(
|
|
62
|
+
function buildCedarlingConfig(params: {
|
|
63
|
+
config: ClawClampConfig;
|
|
64
|
+
policyStoreLocal?: string;
|
|
65
|
+
}): CedarlingConfig {
|
|
62
66
|
const policyStoreLocal =
|
|
63
|
-
config.policyStoreLocal ?? JSON.stringify(buildDefaultPolicyStore());
|
|
67
|
+
params.policyStoreLocal ?? params.config.policyStoreLocal ?? JSON.stringify(buildDefaultPolicyStore());
|
|
64
68
|
|
|
65
69
|
const cedarConfig: CedarlingConfig = {
|
|
66
70
|
CEDARLING_APPLICATION_NAME: "openclaw-clawclamp",
|
|
@@ -72,8 +76,8 @@ function buildCedarlingConfig(config: ClawClampConfig): CedarlingConfig {
|
|
|
72
76
|
CEDARLING_LOG_LEVEL: "WARN",
|
|
73
77
|
};
|
|
74
78
|
|
|
75
|
-
if (config.policyStoreUri) {
|
|
76
|
-
cedarConfig.CEDARLING_POLICY_STORE_URI = config.policyStoreUri;
|
|
79
|
+
if (params.config.policyStoreUri) {
|
|
80
|
+
cedarConfig.CEDARLING_POLICY_STORE_URI = params.config.policyStoreUri;
|
|
77
81
|
} else {
|
|
78
82
|
cedarConfig.CEDARLING_POLICY_STORE_LOCAL = policyStoreLocal;
|
|
79
83
|
}
|
|
@@ -268,7 +272,11 @@ export class ClawClampService {
|
|
|
268
272
|
|
|
269
273
|
private async getCedarlingInstance(): Promise<CedarlingInstance> {
|
|
270
274
|
if (!this.cedarlingPromise) {
|
|
271
|
-
const
|
|
275
|
+
const policyStore = await ensurePolicyStore({ stateDir: this.stateDir, config: this.config });
|
|
276
|
+
const cedarConfig = buildCedarlingConfig({
|
|
277
|
+
config: this.config,
|
|
278
|
+
policyStoreLocal: policyStore.readOnly ? undefined : policyStore.json,
|
|
279
|
+
});
|
|
272
280
|
this.cedarlingPromise = getCedarling(cedarConfig);
|
|
273
281
|
}
|
|
274
282
|
try {
|
package/src/http.ts
CHANGED
|
@@ -4,6 +4,7 @@ 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 { createPolicy, deletePolicy, listPolicies, updatePolicy } from "./policy-store.js";
|
|
7
8
|
import type { ClawClampConfig, ClawClampMode } from "./types.js";
|
|
8
9
|
|
|
9
10
|
const API_PREFIX = "/plugins/clawclamp/api";
|
|
@@ -47,6 +48,91 @@ function parseUrl(rawUrl?: string): URL | null {
|
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
function getHeader(req: IncomingMessage, name: string): string | undefined {
|
|
52
|
+
const raw = req.headers[name.toLowerCase()];
|
|
53
|
+
if (typeof raw === "string") {
|
|
54
|
+
return raw;
|
|
55
|
+
}
|
|
56
|
+
if (Array.isArray(raw)) {
|
|
57
|
+
return raw[0];
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getBearerToken(req: IncomingMessage): string | undefined {
|
|
63
|
+
const raw = getHeader(req, "authorization")?.trim() ?? "";
|
|
64
|
+
if (!raw.toLowerCase().startsWith("bearer ")) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
const token = raw.slice(7).trim();
|
|
68
|
+
return token || undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function hasProxyForwardingHints(req: IncomingMessage): boolean {
|
|
72
|
+
const headers = req.headers ?? {};
|
|
73
|
+
return Boolean(
|
|
74
|
+
headers["x-forwarded-for"] ||
|
|
75
|
+
headers["x-real-ip"] ||
|
|
76
|
+
headers.forwarded ||
|
|
77
|
+
headers["x-forwarded-host"] ||
|
|
78
|
+
headers["x-forwarded-proto"],
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeRemoteClientKey(remoteAddress: string | undefined): string {
|
|
83
|
+
const normalized = remoteAddress?.trim().toLowerCase();
|
|
84
|
+
if (!normalized) {
|
|
85
|
+
return "unknown";
|
|
86
|
+
}
|
|
87
|
+
return normalized.startsWith("::ffff:") ? normalized.slice("::ffff:".length) : normalized;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isLoopbackClientIp(clientIp: string): boolean {
|
|
91
|
+
return clientIp === "127.0.0.1" || clientIp === "::1";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isLoopbackRequest(req: IncomingMessage): boolean {
|
|
95
|
+
const remoteKey = normalizeRemoteClientKey(req.socket?.remoteAddress);
|
|
96
|
+
return isLoopbackClientIp(remoteKey) && !hasProxyForwardingHints(req);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function resolveAuthToken(params: {
|
|
100
|
+
req: IncomingMessage;
|
|
101
|
+
parsed: URL;
|
|
102
|
+
}): string | undefined {
|
|
103
|
+
const queryToken = params.parsed.searchParams.get("token")?.trim();
|
|
104
|
+
if (queryToken) {
|
|
105
|
+
return queryToken;
|
|
106
|
+
}
|
|
107
|
+
const headerToken = getHeader(params.req, "x-openclaw-token")?.trim();
|
|
108
|
+
if (headerToken) {
|
|
109
|
+
return headerToken;
|
|
110
|
+
}
|
|
111
|
+
return getBearerToken(params.req);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isAuthorizedRequest(params: {
|
|
115
|
+
req: IncomingMessage;
|
|
116
|
+
parsed: URL;
|
|
117
|
+
config: ClawClampConfig;
|
|
118
|
+
gatewayToken?: string;
|
|
119
|
+
}): boolean {
|
|
120
|
+
if (isLoopbackRequest(params.req)) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
const token = resolveAuthToken({ req: params.req, parsed: params.parsed });
|
|
124
|
+
if (!token) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
if (params.config.uiToken && token === params.config.uiToken) {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
if (params.gatewayToken && token === params.gatewayToken) {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
50
136
|
async function readJsonBody(req: IncomingMessage, limit = 64_000): Promise<unknown> {
|
|
51
137
|
const chunks: Buffer[] = [];
|
|
52
138
|
let size = 0;
|
|
@@ -109,6 +195,8 @@ export function createClawClampHttpHandler(params: {
|
|
|
109
195
|
stateDir: string;
|
|
110
196
|
config: ClawClampConfig;
|
|
111
197
|
assetsDir: string;
|
|
198
|
+
gatewayToken?: string;
|
|
199
|
+
onPolicyUpdate?: () => Promise<void>;
|
|
112
200
|
}) {
|
|
113
201
|
const assetsDir = path.resolve(params.assetsDir);
|
|
114
202
|
|
|
@@ -118,6 +206,15 @@ export function createClawClampHttpHandler(params: {
|
|
|
118
206
|
return false;
|
|
119
207
|
}
|
|
120
208
|
|
|
209
|
+
if (!isAuthorizedRequest({ req, parsed, config: params.config, gatewayToken: params.gatewayToken })) {
|
|
210
|
+
if (parsed.pathname.startsWith(API_PREFIX)) {
|
|
211
|
+
sendJson(res, 401, { error: "unauthorized" });
|
|
212
|
+
} else {
|
|
213
|
+
sendText(res, 401, "Unauthorized");
|
|
214
|
+
}
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
|
|
121
218
|
if (await serveAsset(req, res, assetsDir, parsed.pathname)) {
|
|
122
219
|
return true;
|
|
123
220
|
}
|
|
@@ -192,6 +289,88 @@ export function createClawClampHttpHandler(params: {
|
|
|
192
289
|
return true;
|
|
193
290
|
}
|
|
194
291
|
|
|
292
|
+
if (apiPath === "policies" && req.method === "GET") {
|
|
293
|
+
if (params.config.policyStoreUri) {
|
|
294
|
+
sendJson(res, 200, { readOnly: true, policies: [], schema: "" });
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
const { policies, schema } = await listPolicies({ stateDir: params.stateDir });
|
|
298
|
+
sendJson(res, 200, { readOnly: false, policies, schema });
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (apiPath === "policies" && req.method === "POST") {
|
|
303
|
+
if (params.config.policyStoreUri) {
|
|
304
|
+
sendJson(res, 400, { error: "policyStoreUri is read-only" });
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
const body = (await readJsonBody(req)) as Record<string, unknown>;
|
|
309
|
+
const content = typeof body?.content === "string" ? body.content : "";
|
|
310
|
+
const id = typeof body?.id === "string" ? body.id : undefined;
|
|
311
|
+
if (!content.trim()) {
|
|
312
|
+
sendJson(res, 400, { error: "content is required" });
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
const policy = await createPolicy({ stateDir: params.stateDir, id, content });
|
|
316
|
+
if (params.onPolicyUpdate) {
|
|
317
|
+
await params.onPolicyUpdate();
|
|
318
|
+
}
|
|
319
|
+
sendJson(res, 200, { policy });
|
|
320
|
+
return true;
|
|
321
|
+
} catch (error) {
|
|
322
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (apiPath.startsWith("policies/") && req.method === "PUT") {
|
|
328
|
+
if (params.config.policyStoreUri) {
|
|
329
|
+
sendJson(res, 400, { error: "policyStoreUri is read-only" });
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
const id = apiPath.slice("policies/".length);
|
|
334
|
+
if (!id) {
|
|
335
|
+
sendJson(res, 400, { error: "policy id required" });
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
const body = (await readJsonBody(req)) as Record<string, unknown>;
|
|
339
|
+
const content = typeof body?.content === "string" ? body.content : "";
|
|
340
|
+
if (!content.trim()) {
|
|
341
|
+
sendJson(res, 400, { error: "content is required" });
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
const policy = await updatePolicy({ stateDir: params.stateDir, id, content });
|
|
345
|
+
if (params.onPolicyUpdate) {
|
|
346
|
+
await params.onPolicyUpdate();
|
|
347
|
+
}
|
|
348
|
+
sendJson(res, 200, { policy });
|
|
349
|
+
return true;
|
|
350
|
+
} catch (error) {
|
|
351
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (apiPath.startsWith("policies/") && req.method === "DELETE") {
|
|
357
|
+
if (params.config.policyStoreUri) {
|
|
358
|
+
sendJson(res, 400, { error: "policyStoreUri is read-only" });
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
const id = apiPath.slice("policies/".length);
|
|
362
|
+
if (!id) {
|
|
363
|
+
sendJson(res, 400, { error: "policy id required" });
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
const ok = await deletePolicy({ stateDir: params.stateDir, id });
|
|
367
|
+
if (params.onPolicyUpdate) {
|
|
368
|
+
await params.onPolicyUpdate();
|
|
369
|
+
}
|
|
370
|
+
sendJson(res, 200, { ok });
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
|
|
195
374
|
if (apiPath === "grants" && req.method === "GET") {
|
|
196
375
|
const grants = await listGrants(params.stateDir);
|
|
197
376
|
sendJson(res, 200, { grants });
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk";
|
|
5
|
+
import type { ClawClampConfig } from "./types.js";
|
|
6
|
+
import { withStateFileLock } from "./storage.js";
|
|
7
|
+
import { buildDefaultPolicyStore } from "./policy.js";
|
|
8
|
+
|
|
9
|
+
const POLICY_FILE = "policy-store.json";
|
|
10
|
+
|
|
11
|
+
export type PolicyEntry = { id: string; content: string };
|
|
12
|
+
|
|
13
|
+
export type PolicyStoreSnapshot = {
|
|
14
|
+
cedar_version: string;
|
|
15
|
+
schema: Record<string, string>;
|
|
16
|
+
policies: Record<string, { policy_content: string }>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function resolvePolicyPath(stateDir: string): string {
|
|
20
|
+
return path.join(stateDir, "clawclamp", POLICY_FILE);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function decodeBase64(value: string): string {
|
|
24
|
+
return Buffer.from(value, "base64").toString("utf8");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function encodeBase64(value: string): string {
|
|
28
|
+
return Buffer.from(value, "utf8").toString("base64");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function readPolicyStore(stateDir: string): Promise<PolicyStoreSnapshot> {
|
|
32
|
+
const filePath = resolvePolicyPath(stateDir);
|
|
33
|
+
const { value } = await readJsonFileWithFallback<PolicyStoreSnapshot>(
|
|
34
|
+
filePath,
|
|
35
|
+
buildDefaultPolicyStore() as PolicyStoreSnapshot,
|
|
36
|
+
);
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function writePolicyStore(stateDir: string, store: PolicyStoreSnapshot): Promise<void> {
|
|
41
|
+
const filePath = resolvePolicyPath(stateDir);
|
|
42
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
43
|
+
await writeJsonFileAtomically(filePath, store);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function ensurePolicyStore(params: {
|
|
47
|
+
stateDir: string;
|
|
48
|
+
config: ClawClampConfig;
|
|
49
|
+
}): Promise<{ json: string; readOnly: boolean } | { json?: undefined; readOnly: true }> {
|
|
50
|
+
if (params.config.policyStoreUri) {
|
|
51
|
+
return { readOnly: true };
|
|
52
|
+
}
|
|
53
|
+
return withStateFileLock(params.stateDir, "policy-store", async () => {
|
|
54
|
+
const filePath = resolvePolicyPath(params.stateDir);
|
|
55
|
+
try {
|
|
56
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
57
|
+
return { json: raw, readOnly: false };
|
|
58
|
+
} catch (error) {
|
|
59
|
+
const code = (error as { code?: string }).code;
|
|
60
|
+
if (code !== "ENOENT") {
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const initial = params.config.policyStoreLocal
|
|
66
|
+
? (JSON.parse(params.config.policyStoreLocal) as PolicyStoreSnapshot)
|
|
67
|
+
: (buildDefaultPolicyStore() as PolicyStoreSnapshot);
|
|
68
|
+
await writePolicyStore(params.stateDir, initial);
|
|
69
|
+
return { json: JSON.stringify(initial), readOnly: false };
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function listPolicies(params: {
|
|
74
|
+
stateDir: string;
|
|
75
|
+
}): Promise<{ policies: PolicyEntry[]; schema: string }> {
|
|
76
|
+
return withStateFileLock(params.stateDir, "policy-store", async () => {
|
|
77
|
+
const store = await readPolicyStore(params.stateDir);
|
|
78
|
+
const policies: PolicyEntry[] = Object.entries(store.policies ?? {}).map(([id, payload]) => ({
|
|
79
|
+
id,
|
|
80
|
+
content: decodeBase64(payload.policy_content ?? ""),
|
|
81
|
+
}));
|
|
82
|
+
const schemaEncoded = store.schema?.["schema.json"] ?? "";
|
|
83
|
+
return { policies, schema: schemaEncoded ? decodeBase64(schemaEncoded) : "" };
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function createPolicy(params: {
|
|
88
|
+
stateDir: string;
|
|
89
|
+
id?: string;
|
|
90
|
+
content: string;
|
|
91
|
+
}): Promise<PolicyEntry> {
|
|
92
|
+
return withStateFileLock(params.stateDir, "policy-store", async () => {
|
|
93
|
+
const store = await readPolicyStore(params.stateDir);
|
|
94
|
+
const id = params.id?.trim() || `clawclamp-${randomUUID()}`;
|
|
95
|
+
if (!store.policies) {
|
|
96
|
+
store.policies = {};
|
|
97
|
+
}
|
|
98
|
+
if (store.policies[id]) {
|
|
99
|
+
throw new Error("policy id already exists");
|
|
100
|
+
}
|
|
101
|
+
store.policies[id] = { policy_content: encodeBase64(params.content) };
|
|
102
|
+
await writePolicyStore(params.stateDir, store);
|
|
103
|
+
return { id, content: params.content };
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function updatePolicy(params: {
|
|
108
|
+
stateDir: string;
|
|
109
|
+
id: string;
|
|
110
|
+
content: string;
|
|
111
|
+
}): Promise<PolicyEntry> {
|
|
112
|
+
return withStateFileLock(params.stateDir, "policy-store", async () => {
|
|
113
|
+
const store = await readPolicyStore(params.stateDir);
|
|
114
|
+
if (!store.policies?.[params.id]) {
|
|
115
|
+
throw new Error("policy id not found");
|
|
116
|
+
}
|
|
117
|
+
store.policies[params.id] = { policy_content: encodeBase64(params.content) };
|
|
118
|
+
await writePolicyStore(params.stateDir, store);
|
|
119
|
+
return { id: params.id, content: params.content };
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function deletePolicy(params: { stateDir: string; id: string }): Promise<boolean> {
|
|
124
|
+
return withStateFileLock(params.stateDir, "policy-store", async () => {
|
|
125
|
+
const store = await readPolicyStore(params.stateDir);
|
|
126
|
+
if (!store.policies?.[params.id]) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
delete store.policies[params.id];
|
|
130
|
+
await writePolicyStore(params.stateDir, store);
|
|
131
|
+
return true;
|
|
132
|
+
});
|
|
133
|
+
}
|
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
|