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 CHANGED
@@ -20,10 +20,12 @@ plugins:
20
20
  clawclamp:
21
21
  enabled: true
22
22
  config:
23
- mode: enforce
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 = "<div>时间</div><div>工具</div><div>决策</div><div>风险</div><div>说明</div>";
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: #f5f1ea;
3
+ --bg: #f2f3f5;
4
4
  --panel: #ffffff;
5
- --ink: #1b1b1b;
6
- --muted: #5b5b5b;
7
- --accent: #1a4dff;
8
- --accent-2: #ff8b2c;
9
- --warn: #d64b31;
10
- --border: #e5ddd0;
11
- --shadow: 0 12px 40px rgba(0, 0, 0, 0.08);
12
- --radius: 16px;
13
- --mono: "IBM Plex Mono", "SFMono-Regular", "Menlo", "Consolas", monospace;
14
- --sans: "Space Grotesk", "Avenir Next", "Segoe UI", sans-serif;
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(--sans);
24
- background: radial-gradient(circle at top, #fdfbf7 0%, var(--bg) 50%, #efe7db 100%);
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: 1180px;
29
+ max-width: 1120px;
30
30
  margin: 0 auto;
31
- padding: 32px 24px 48px;
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: 24px;
39
- padding: 24px;
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: 24px;
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 6px;
57
- font-size: 2.2rem;
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(280px, 1fr));
74
- gap: 20px;
75
- margin-bottom: 24px;
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: 20px;
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 12px;
88
- font-size: 1.2rem;
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: 16px;
97
+ gap: 12px;
96
98
  }
97
99
 
98
100
  .mode-actions {
99
101
  display: flex;
100
102
  flex-direction: column;
101
- gap: 8px;
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.4rem;
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: 10px 16px;
122
- border-radius: 999px;
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: 12px;
167
- padding: 10px 12px;
168
- font-size: 0.95rem;
169
- font-family: var(--sans);
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: 12px;
182
- border-radius: 12px;
191
+ padding: 10px;
192
+ border-radius: 8px;
183
193
  border: 1px solid var(--border);
184
- background: #faf7f1;
185
- font-size: 0.9rem;
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: 8px;
195
- margin-top: 12px;
204
+ gap: 6px;
205
+ margin-top: 10px;
196
206
  }
197
207
 
198
208
  .table-row {
199
209
  display: grid;
200
- grid-template-columns: 140px 120px 1fr 100px 120px;
201
- gap: 12px;
210
+ grid-template-columns: 110px 110px 90px 70px 1fr 120px;
211
+ gap: 10px;
202
212
  align-items: center;
203
- padding: 10px 12px;
204
- border-radius: 12px;
213
+ padding: 8px 10px;
214
+ border-radius: 8px;
205
215
  border: 1px solid var(--border);
206
- background: #fcfbf8;
207
- font-size: 0.85rem;
216
+ background: #fdfdfd;
217
+ font-size: 0.78rem;
208
218
  }
209
219
 
210
220
  .table-row.header {
211
- background: #f0e8db;
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: 4px 8px;
338
+ padding: 3px 6px;
221
339
  border-radius: 999px;
222
- font-size: 0.75rem;
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(26, 77, 255, 0.15);
230
- color: #1a4dff;
347
+ background: rgba(0, 163, 255, 0.15);
348
+ color: #0086cc;
231
349
  }
232
350
 
233
351
  .badge.deny {
234
- background: rgba(214, 75, 49, 0.15);
235
- color: #d64b31;
352
+ background: rgba(255, 91, 77, 0.15);
353
+ color: #d9392c;
236
354
  }
237
355
 
238
356
  .badge.gray {
239
- background: rgba(255, 139, 44, 0.2);
240
- color: #c15c00;
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: 120px 100px 1fr;
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: "gateway",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawclamp",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "OpenClaw Cedar authorization guard with audit UI",
5
5
  "type": "module",
6
6
  "dependencies": {
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: "enforce",
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(config: ClawClampConfig): CedarlingConfig {
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 cedarConfig = buildCedarlingConfig(this.config);
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
package/src/types.ts CHANGED
@@ -8,6 +8,7 @@ export type ClawClampConfig = {
8
8
  principalId: string;
9
9
  policyStoreUri?: string;
10
10
  policyStoreLocal?: string;
11
+ uiToken?: string;
11
12
  policyFailOpen: boolean;
12
13
  risk: {
13
14
  default: RiskLevel;