clawclamp 0.1.6 → 0.1.7

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
@@ -24,6 +24,8 @@ plugins:
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,13 @@ 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.
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,24 +119,87 @@ 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 isDenied = deniedTools.has(entry.toolName) || deniedTools.has("*");
124
+ const canDeny = entry.decision === "allow" && !isDenied;
110
125
  row.innerHTML = `
111
126
  <div>${formatTime(entry.timestamp)}</div>
112
127
  <div class="mono">${entry.toolName}</div>
113
128
  <div class="badge ${badgeClass}">${decisionLabel(entry.decision)}</div>
114
129
  <div>${riskLabel(entry.risk)}</div>
115
130
  <div>${entry.reason || entry.error || ""}</div>
131
+ <div class="actions">
132
+ ${canAllow ? "<button class=\"btn mini\" data-action=\"allow\">一键允许</button>" : ""}
133
+ ${canDeny ? "<button class=\"btn mini warn\" data-action=\"deny\">一键拒绝</button>" : ""}
134
+ </div>
116
135
  `;
136
+ const allowBtn = row.querySelector("button[data-action=\"allow\"]");
137
+ if (allowBtn) {
138
+ allowBtn.addEventListener("click", async () => {
139
+ await fetchJson(`${API_BASE}/grants`, {
140
+ method: "POST",
141
+ body: JSON.stringify({ toolName: entry.toolName }),
142
+ });
143
+ await refreshAll();
144
+ });
145
+ }
146
+ const denyBtn = row.querySelector("button[data-action=\"deny\"]");
147
+ if (denyBtn) {
148
+ denyBtn.addEventListener("click", async () => {
149
+ await fetchJson(`${API_BASE}/denies`, {
150
+ method: "POST",
151
+ body: JSON.stringify({ toolName: entry.toolName }),
152
+ });
153
+ await refreshAll();
154
+ });
155
+ }
117
156
  auditEl.appendChild(row);
118
157
  });
119
158
  }
120
159
 
160
+ function renderPolicyList(list) {
161
+ policies = list;
162
+ if (!list.length) {
163
+ policyListEl.innerHTML = "<div class=\"note\">暂无策略。</div>";
164
+ return;
165
+ }
166
+ policyListEl.innerHTML = "";
167
+ list.forEach((policy) => {
168
+ const item = document.createElement("button");
169
+ item.className = "policy-item";
170
+ item.textContent = policy.id;
171
+ item.addEventListener("click", () => {
172
+ policyIdInput.value = policy.id;
173
+ policyContentInput.value = policy.content || "";
174
+ policyStatusEl.textContent = "";
175
+ });
176
+ policyListEl.appendChild(item);
177
+ });
178
+ }
179
+
180
+ async function refreshPolicies() {
181
+ const result = await fetchJson(`${API_BASE}/policies`);
182
+ policyReadOnly = result.readOnly === true;
183
+ renderPolicyList(result.policies || []);
184
+ policySchemaEl.textContent = result.schema || "";
185
+ policyCreateBtn.disabled = policyReadOnly;
186
+ policyUpdateBtn.disabled = policyReadOnly;
187
+ policyDeleteBtn.disabled = policyReadOnly;
188
+ if (policyReadOnly) {
189
+ policyStatusEl.textContent = "policyStoreUri 模式下为只读。";
190
+ }
191
+ }
192
+
121
193
  async function refreshAll() {
122
194
  const state = await fetchJson(`${API_BASE}/state`);
123
195
  renderMode(state);
124
196
  const grants = await fetchJson(`${API_BASE}/grants`);
125
197
  renderGrants(grants.grants || []);
198
+ const denies = await fetchJson(`${API_BASE}/denies`);
199
+ deniedTools = new Set(denies.tools || []);
126
200
  const logs = await fetchJson(`${API_BASE}/logs`);
127
201
  renderAudit(logs.entries || []);
202
+ await refreshPolicies();
128
203
  }
129
204
 
130
205
  refreshBtn.addEventListener("click", () => {
@@ -164,3 +239,53 @@ grantForm.addEventListener("submit", async (event) => {
164
239
 
165
240
  refreshAll();
166
241
  setInterval(refreshAll, 10_000);
242
+
243
+ policyCreateBtn.addEventListener("click", async () => {
244
+ const id = policyIdInput.value.trim();
245
+ const content = policyContentInput.value.trim();
246
+ if (!content) {
247
+ policyStatusEl.textContent = "请输入 Policy 内容。";
248
+ return;
249
+ }
250
+ const body = { content, ...(id ? { id } : {}) };
251
+ await fetchJson(`${API_BASE}/policies`, {
252
+ method: "POST",
253
+ body: JSON.stringify(body),
254
+ });
255
+ policyStatusEl.textContent = "已新增策略。";
256
+ await refreshPolicies();
257
+ });
258
+
259
+ policyUpdateBtn.addEventListener("click", async () => {
260
+ const id = policyIdInput.value.trim();
261
+ const content = policyContentInput.value.trim();
262
+ if (!id) {
263
+ policyStatusEl.textContent = "请选择或填写 Policy ID。";
264
+ return;
265
+ }
266
+ if (!content) {
267
+ policyStatusEl.textContent = "请输入 Policy 内容。";
268
+ return;
269
+ }
270
+ await fetchJson(`${API_BASE}/policies/${encodeURIComponent(id)}`, {
271
+ method: "PUT",
272
+ body: JSON.stringify({ content }),
273
+ });
274
+ policyStatusEl.textContent = "已保存策略。";
275
+ await refreshPolicies();
276
+ });
277
+
278
+ policyDeleteBtn.addEventListener("click", async () => {
279
+ const id = policyIdInput.value.trim();
280
+ if (!id) {
281
+ policyStatusEl.textContent = "请选择或填写 Policy ID。";
282
+ return;
283
+ }
284
+ await fetchJson(`${API_BASE}/policies/${encodeURIComponent(id)}`, {
285
+ method: "DELETE",
286
+ });
287
+ policyStatusEl.textContent = "已删除策略。";
288
+ policyIdInput.value = "";
289
+ policyContentInput.value = "";
290
+ await refreshPolicies();
291
+ });
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,127 @@ 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
+ .policy-grid {
236
+ display: grid;
237
+ grid-template-columns: 220px 1fr;
238
+ gap: 12px;
239
+ margin-top: 10px;
240
+ }
241
+
242
+ .policy-list {
243
+ display: grid;
244
+ gap: 6px;
245
+ align-content: start;
246
+ max-height: 360px;
247
+ overflow: auto;
248
+ padding-right: 4px;
249
+ }
250
+
251
+ .policy-item {
252
+ padding: 6px 8px;
253
+ border-radius: 6px;
254
+ border: 1px solid var(--border);
255
+ background: #f7f8fb;
256
+ font-family: var(--mono);
257
+ font-size: 0.75rem;
258
+ text-align: left;
259
+ cursor: pointer;
260
+ }
261
+
262
+ .policy-editor {
263
+ display: grid;
264
+ grid-template-columns: 1fr 1fr;
265
+ gap: 12px;
266
+ }
267
+
268
+ .policy-form {
269
+ display: grid;
270
+ gap: 8px;
271
+ }
272
+
273
+ .policy-form label {
274
+ display: grid;
275
+ gap: 6px;
276
+ font-size: 0.8rem;
277
+ color: var(--muted);
278
+ }
279
+
280
+ .policy-form textarea {
281
+ border: 1px solid var(--border);
282
+ border-radius: 8px;
283
+ padding: 8px 10px;
284
+ font-size: 0.8rem;
285
+ font-family: var(--mono);
286
+ min-height: 160px;
287
+ resize: vertical;
288
+ }
289
+
290
+ .policy-actions {
291
+ display: flex;
292
+ gap: 6px;
293
+ flex-wrap: wrap;
294
+ }
295
+
296
+ .policy-schema pre {
297
+ margin: 0;
298
+ padding: 10px;
299
+ border: 1px solid var(--border);
300
+ border-radius: 8px;
301
+ background: #f3f5f9;
302
+ font-size: 0.75rem;
303
+ line-height: 1.4;
304
+ max-height: 260px;
305
+ overflow: auto;
306
+ }
307
+
308
+ @media (max-width: 900px) {
309
+ .policy-grid {
310
+ grid-template-columns: 1fr;
311
+ }
312
+ .policy-editor {
313
+ grid-template-columns: 1fr;
314
+ }
315
+ }
316
+
219
317
  .badge {
220
- padding: 4px 8px;
318
+ padding: 3px 6px;
221
319
  border-radius: 999px;
222
- font-size: 0.75rem;
320
+ font-size: 0.68rem;
223
321
  text-transform: uppercase;
224
322
  font-weight: 700;
225
323
  justify-self: start;
226
324
  }
227
325
 
228
326
  .badge.allow {
229
- background: rgba(26, 77, 255, 0.15);
230
- color: #1a4dff;
327
+ background: rgba(0, 163, 255, 0.15);
328
+ color: #0086cc;
231
329
  }
232
330
 
233
331
  .badge.deny {
234
- background: rgba(214, 75, 49, 0.15);
235
- color: #d64b31;
332
+ background: rgba(255, 91, 77, 0.15);
333
+ color: #d9392c;
236
334
  }
237
335
 
238
336
  .badge.gray {
239
- background: rgba(255, 139, 44, 0.2);
240
- color: #c15c00;
337
+ background: rgba(124, 140, 255, 0.2);
338
+ color: #4f5bff;
241
339
  }
242
340
 
243
341
  @media (max-width: 900px) {
244
342
  .table-row {
245
- grid-template-columns: 120px 100px 1fr;
246
- grid-template-rows: auto auto;
343
+ grid-template-columns: 100px 1fr;
344
+ grid-template-rows: auto auto auto;
247
345
  }
346
+ .table-row > :nth-child(3),
248
347
  .table-row > :nth-child(4),
249
- .table-row > :nth-child(5) {
348
+ .table-row > :nth-child(5),
349
+ .table-row > :nth-child(6) {
250
350
  grid-column: 1 / -1;
251
351
  }
252
352
  }
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.7",
4
4
  "description": "OpenClaw Cedar authorization guard with audit UI",
5
5
  "type": "module",
6
6
  "dependencies": {
package/src/config.ts CHANGED
@@ -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/denies.ts ADDED
@@ -0,0 +1,75 @@
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
+ }
package/src/guard.ts CHANGED
@@ -9,7 +9,9 @@ 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";
12
13
  import { buildDefaultPolicyStore } from "./policy.js";
14
+ import { ensurePolicyStore } from "./policy-store.js";
13
15
  import type { AuditEntry, ClawClampConfig, ClawClampMode, RiskLevel } from "./types.js";
14
16
 
15
17
  type CedarDecision = "allow" | "deny" | "error";
@@ -58,9 +60,12 @@ function summarizeParams(
58
60
  return summary;
59
61
  }
60
62
 
61
- function buildCedarlingConfig(config: ClawClampConfig): CedarlingConfig {
63
+ function buildCedarlingConfig(params: {
64
+ config: ClawClampConfig;
65
+ policyStoreLocal?: string;
66
+ }): CedarlingConfig {
62
67
  const policyStoreLocal =
63
- config.policyStoreLocal ?? JSON.stringify(buildDefaultPolicyStore());
68
+ params.policyStoreLocal ?? params.config.policyStoreLocal ?? JSON.stringify(buildDefaultPolicyStore());
64
69
 
65
70
  const cedarConfig: CedarlingConfig = {
66
71
  CEDARLING_APPLICATION_NAME: "openclaw-clawclamp",
@@ -72,8 +77,8 @@ function buildCedarlingConfig(config: ClawClampConfig): CedarlingConfig {
72
77
  CEDARLING_LOG_LEVEL: "WARN",
73
78
  };
74
79
 
75
- if (config.policyStoreUri) {
76
- cedarConfig.CEDARLING_POLICY_STORE_URI = config.policyStoreUri;
80
+ if (params.config.policyStoreUri) {
81
+ cedarConfig.CEDARLING_POLICY_STORE_URI = params.config.policyStoreUri;
77
82
  } else {
78
83
  cedarConfig.CEDARLING_POLICY_STORE_LOCAL = policyStoreLocal;
79
84
  }
@@ -168,9 +173,13 @@ export class ClawClampService {
168
173
  const auditId = createAuditEntryId();
169
174
  const mode = await this.getEffectiveMode();
170
175
  const grant = await this.resolveGrant(toolName);
176
+ const deniedTools = await listDeniedTools(this.stateDir);
177
+ const explicitlyDenied = isToolDenied(deniedTools, toolName);
171
178
 
172
179
  let cedarDecision: CedarEvaluation;
173
- if (this.config.enabled) {
180
+ if (explicitlyDenied) {
181
+ cedarDecision = { decision: "deny", reason: "Denied by operator" };
182
+ } else if (this.config.enabled) {
174
183
  const cedarling = await this.getCedarlingInstance();
175
184
  cedarDecision = await evaluateCedar({
176
185
  cedarling,
@@ -268,7 +277,11 @@ export class ClawClampService {
268
277
 
269
278
  private async getCedarlingInstance(): Promise<CedarlingInstance> {
270
279
  if (!this.cedarlingPromise) {
271
- const cedarConfig = buildCedarlingConfig(this.config);
280
+ const policyStore = await ensurePolicyStore({ stateDir: this.stateDir, config: this.config });
281
+ const cedarConfig = buildCedarlingConfig({
282
+ config: this.config,
283
+ policyStoreLocal: policyStore.readOnly ? undefined : policyStore.json,
284
+ });
272
285
  this.cedarlingPromise = getCedarling(cedarConfig);
273
286
  }
274
287
  try {
package/src/http.ts CHANGED
@@ -4,6 +4,8 @@ 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
+ import { createPolicy, deletePolicy, listPolicies, updatePolicy } from "./policy-store.js";
7
9
  import type { ClawClampConfig, ClawClampMode } from "./types.js";
8
10
 
9
11
  const API_PREFIX = "/plugins/clawclamp/api";
@@ -47,6 +49,91 @@ function parseUrl(rawUrl?: string): URL | null {
47
49
  }
48
50
  }
49
51
 
52
+ function getHeader(req: IncomingMessage, name: string): string | undefined {
53
+ const raw = req.headers[name.toLowerCase()];
54
+ if (typeof raw === "string") {
55
+ return raw;
56
+ }
57
+ if (Array.isArray(raw)) {
58
+ return raw[0];
59
+ }
60
+ return undefined;
61
+ }
62
+
63
+ function getBearerToken(req: IncomingMessage): string | undefined {
64
+ const raw = getHeader(req, "authorization")?.trim() ?? "";
65
+ if (!raw.toLowerCase().startsWith("bearer ")) {
66
+ return undefined;
67
+ }
68
+ const token = raw.slice(7).trim();
69
+ return token || undefined;
70
+ }
71
+
72
+ function hasProxyForwardingHints(req: IncomingMessage): boolean {
73
+ const headers = req.headers ?? {};
74
+ return Boolean(
75
+ headers["x-forwarded-for"] ||
76
+ headers["x-real-ip"] ||
77
+ headers.forwarded ||
78
+ headers["x-forwarded-host"] ||
79
+ headers["x-forwarded-proto"],
80
+ );
81
+ }
82
+
83
+ function normalizeRemoteClientKey(remoteAddress: string | undefined): string {
84
+ const normalized = remoteAddress?.trim().toLowerCase();
85
+ if (!normalized) {
86
+ return "unknown";
87
+ }
88
+ return normalized.startsWith("::ffff:") ? normalized.slice("::ffff:".length) : normalized;
89
+ }
90
+
91
+ function isLoopbackClientIp(clientIp: string): boolean {
92
+ return clientIp === "127.0.0.1" || clientIp === "::1";
93
+ }
94
+
95
+ function isLoopbackRequest(req: IncomingMessage): boolean {
96
+ const remoteKey = normalizeRemoteClientKey(req.socket?.remoteAddress);
97
+ return isLoopbackClientIp(remoteKey) && !hasProxyForwardingHints(req);
98
+ }
99
+
100
+ function resolveAuthToken(params: {
101
+ req: IncomingMessage;
102
+ parsed: URL;
103
+ }): string | undefined {
104
+ const queryToken = params.parsed.searchParams.get("token")?.trim();
105
+ if (queryToken) {
106
+ return queryToken;
107
+ }
108
+ const headerToken = getHeader(params.req, "x-openclaw-token")?.trim();
109
+ if (headerToken) {
110
+ return headerToken;
111
+ }
112
+ return getBearerToken(params.req);
113
+ }
114
+
115
+ function isAuthorizedRequest(params: {
116
+ req: IncomingMessage;
117
+ parsed: URL;
118
+ config: ClawClampConfig;
119
+ gatewayToken?: string;
120
+ }): boolean {
121
+ if (isLoopbackRequest(params.req)) {
122
+ return true;
123
+ }
124
+ const token = resolveAuthToken({ req: params.req, parsed: params.parsed });
125
+ if (!token) {
126
+ return false;
127
+ }
128
+ if (params.config.uiToken && token === params.config.uiToken) {
129
+ return true;
130
+ }
131
+ if (params.gatewayToken && token === params.gatewayToken) {
132
+ return true;
133
+ }
134
+ return false;
135
+ }
136
+
50
137
  async function readJsonBody(req: IncomingMessage, limit = 64_000): Promise<unknown> {
51
138
  const chunks: Buffer[] = [];
52
139
  let size = 0;
@@ -109,6 +196,8 @@ export function createClawClampHttpHandler(params: {
109
196
  stateDir: string;
110
197
  config: ClawClampConfig;
111
198
  assetsDir: string;
199
+ gatewayToken?: string;
200
+ onPolicyUpdate?: () => Promise<void>;
112
201
  }) {
113
202
  const assetsDir = path.resolve(params.assetsDir);
114
203
 
@@ -118,6 +207,15 @@ export function createClawClampHttpHandler(params: {
118
207
  return false;
119
208
  }
120
209
 
210
+ if (!isAuthorizedRequest({ req, parsed, config: params.config, gatewayToken: params.gatewayToken })) {
211
+ if (parsed.pathname.startsWith(API_PREFIX)) {
212
+ sendJson(res, 401, { error: "unauthorized" });
213
+ } else {
214
+ sendText(res, 401, "Unauthorized");
215
+ }
216
+ return true;
217
+ }
218
+
121
219
  if (await serveAsset(req, res, assetsDir, parsed.pathname)) {
122
220
  return true;
123
221
  }
@@ -192,6 +290,122 @@ export function createClawClampHttpHandler(params: {
192
290
  return true;
193
291
  }
194
292
 
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
+ if (apiPath === "policies" && req.method === "GET") {
328
+ if (params.config.policyStoreUri) {
329
+ sendJson(res, 200, { readOnly: true, policies: [], schema: "" });
330
+ return true;
331
+ }
332
+ const { policies, schema } = await listPolicies({ stateDir: params.stateDir });
333
+ sendJson(res, 200, { readOnly: false, policies, schema });
334
+ return true;
335
+ }
336
+
337
+ if (apiPath === "policies" && req.method === "POST") {
338
+ if (params.config.policyStoreUri) {
339
+ sendJson(res, 400, { error: "policyStoreUri is read-only" });
340
+ return true;
341
+ }
342
+ try {
343
+ const body = (await readJsonBody(req)) as Record<string, unknown>;
344
+ const content = typeof body?.content === "string" ? body.content : "";
345
+ const id = typeof body?.id === "string" ? body.id : undefined;
346
+ if (!content.trim()) {
347
+ sendJson(res, 400, { error: "content is required" });
348
+ return true;
349
+ }
350
+ const policy = await createPolicy({ stateDir: params.stateDir, id, content });
351
+ if (params.onPolicyUpdate) {
352
+ await params.onPolicyUpdate();
353
+ }
354
+ sendJson(res, 200, { policy });
355
+ return true;
356
+ } catch (error) {
357
+ sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
358
+ return true;
359
+ }
360
+ }
361
+
362
+ if (apiPath.startsWith("policies/") && req.method === "PUT") {
363
+ if (params.config.policyStoreUri) {
364
+ sendJson(res, 400, { error: "policyStoreUri is read-only" });
365
+ return true;
366
+ }
367
+ try {
368
+ const id = apiPath.slice("policies/".length);
369
+ if (!id) {
370
+ sendJson(res, 400, { error: "policy id required" });
371
+ return true;
372
+ }
373
+ const body = (await readJsonBody(req)) as Record<string, unknown>;
374
+ const content = typeof body?.content === "string" ? body.content : "";
375
+ if (!content.trim()) {
376
+ sendJson(res, 400, { error: "content is required" });
377
+ return true;
378
+ }
379
+ const policy = await updatePolicy({ stateDir: params.stateDir, id, content });
380
+ if (params.onPolicyUpdate) {
381
+ await params.onPolicyUpdate();
382
+ }
383
+ sendJson(res, 200, { policy });
384
+ return true;
385
+ } catch (error) {
386
+ sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
387
+ return true;
388
+ }
389
+ }
390
+
391
+ if (apiPath.startsWith("policies/") && req.method === "DELETE") {
392
+ if (params.config.policyStoreUri) {
393
+ sendJson(res, 400, { error: "policyStoreUri is read-only" });
394
+ return true;
395
+ }
396
+ const id = apiPath.slice("policies/".length);
397
+ if (!id) {
398
+ sendJson(res, 400, { error: "policy id required" });
399
+ return true;
400
+ }
401
+ const ok = await deletePolicy({ stateDir: params.stateDir, id });
402
+ if (params.onPolicyUpdate) {
403
+ await params.onPolicyUpdate();
404
+ }
405
+ sendJson(res, 200, { ok });
406
+ return true;
407
+ }
408
+
195
409
  if (apiPath === "grants" && req.method === "GET") {
196
410
  const grants = await listGrants(params.stateDir);
197
411
  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/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;