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 +12 -0
- package/assets/app.js +126 -1
- package/assets/index.html +30 -0
- package/assets/styles.css +159 -59
- package/index.ts +7 -1
- package/package.json +1 -1
- package/src/config.ts +5 -0
- package/src/denies.ts +75 -0
- package/src/guard.ts +19 -6
- package/src/http.ts +214 -0
- package/src/policy-store.ts +133 -0
- package/src/types.ts +1 -0
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 =
|
|
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: #
|
|
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,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:
|
|
318
|
+
padding: 3px 6px;
|
|
221
319
|
border-radius: 999px;
|
|
222
|
-
font-size: 0.
|
|
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(
|
|
230
|
-
color: #
|
|
327
|
+
background: rgba(0, 163, 255, 0.15);
|
|
328
|
+
color: #0086cc;
|
|
231
329
|
}
|
|
232
330
|
|
|
233
331
|
.badge.deny {
|
|
234
|
-
background: rgba(
|
|
235
|
-
color: #
|
|
332
|
+
background: rgba(255, 91, 77, 0.15);
|
|
333
|
+
color: #d9392c;
|
|
236
334
|
}
|
|
237
335
|
|
|
238
336
|
.badge.gray {
|
|
239
|
-
background: rgba(
|
|
240
|
-
color: #
|
|
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:
|
|
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: "
|
|
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
|
@@ -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(
|
|
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 (
|
|
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
|
|
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
|
+
}
|