@plusplus7/clawclamp 0.1.0
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/LICENSE +21 -0
- package/README.md +94 -0
- package/README.zh-CN.md +94 -0
- package/RELEASE.md +34 -0
- package/assets/app.js +362 -0
- package/assets/index.html +125 -0
- package/assets/styles.css +432 -0
- package/index.ts +45 -0
- package/openclaw.plugin.json +10 -0
- package/package.json +17 -0
- package/src/audit.ts +94 -0
- package/src/cedarling.ts +46 -0
- package/src/config.ts +249 -0
- package/src/grants.ts +183 -0
- package/src/guard.test.ts +69 -0
- package/src/guard.ts +342 -0
- package/src/http.ts +433 -0
- package/src/mode.ts +48 -0
- package/src/policy-store.ts +186 -0
- package/src/policy.ts +74 -0
- package/src/storage.ts +23 -0
- package/src/types.ts +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Clawclamp contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Clawclamp
|
|
2
|
+
|
|
3
|
+
[中文说明](./README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
Clawclamp adds Cedar-based authorization to OpenClaw tool calls. It evaluates every tool invocation via a Cedar policy, records allow/deny decisions to an audit log, and exposes a gateway UI for reviewing logs and granting short-term approvals.
|
|
11
|
+
|
|
12
|
+
This repository is a vibe-coding project and most of the implementation was generated with AI assistance.
|
|
13
|
+
|
|
14
|
+
## Screenshots
|
|
15
|
+
|
|
16
|
+
Add screenshots under `screenshots/` and reference them here when the repository is published on GitHub.
|
|
17
|
+
|
|
18
|
+
- Suggested files: `screenshots/policy-lab.png`, `screenshots/audit-log.png`
|
|
19
|
+
- Suggested captures: the policy editor, the audit table, and the mode / grant controls
|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
- Cedar policy enforcement for `before_tool_call`.
|
|
24
|
+
- Long-term authorization via Cedar policy.
|
|
25
|
+
- Short-term authorization via time-bound Cedar policy.
|
|
26
|
+
- Audit UI for allowed/denied/gray-mode tool calls.
|
|
27
|
+
- Gray mode: denied calls are still executed but logged as overrides.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
From npm:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
openclaw plugins install @plusplus7/clawclamp
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Or in config / plugin management, use the package name `@plusplus7/clawclamp`.
|
|
38
|
+
|
|
39
|
+
## GitHub
|
|
40
|
+
|
|
41
|
+
Recommended repository description:
|
|
42
|
+
|
|
43
|
+
> Cedar-based authorization and audit plugin for OpenClaw, built as a vibe-coding / AI-assisted project.
|
|
44
|
+
|
|
45
|
+
Suggested topics:
|
|
46
|
+
|
|
47
|
+
`openclaw`, `cedar`, `authorization`, `audit`, `plugin`, `ai-generated`, `vibe-coding`
|
|
48
|
+
|
|
49
|
+
## Configuration
|
|
50
|
+
|
|
51
|
+
Configure under `plugins.entries.clawclamp.config`:
|
|
52
|
+
|
|
53
|
+
```yaml
|
|
54
|
+
plugins:
|
|
55
|
+
entries:
|
|
56
|
+
clawclamp:
|
|
57
|
+
enabled: true
|
|
58
|
+
config:
|
|
59
|
+
mode: gray
|
|
60
|
+
principalId: openclaw
|
|
61
|
+
policyStoreUri: file:///path/to/policy-store.json
|
|
62
|
+
policyFailOpen: false
|
|
63
|
+
# 可选:UI 访问令牌(非 loopback 时可通过 ?token= 或 X-OpenClaw-Token 访问)
|
|
64
|
+
# uiToken: "your-ui-token"
|
|
65
|
+
risk:
|
|
66
|
+
default: high
|
|
67
|
+
overrides:
|
|
68
|
+
read: low
|
|
69
|
+
web_search: medium
|
|
70
|
+
exec: high
|
|
71
|
+
grants:
|
|
72
|
+
defaultTtlSeconds: 900
|
|
73
|
+
maxTtlSeconds: 3600
|
|
74
|
+
audit:
|
|
75
|
+
maxEntries: 500
|
|
76
|
+
includeParams: true
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`policyStoreUri` points to a Cedar policy store JSON (file:// or https://). `policyStoreLocal` can be set to a raw JSON string for the policy store. If omitted, the plugin uses a built-in policy store that denies all tool calls unless a grant is active or an explicit permit policy exists.
|
|
80
|
+
|
|
81
|
+
## UI
|
|
82
|
+
|
|
83
|
+
Open the gateway path `/plugins/clawclamp` to view audit logs, toggle gray mode, and create short-term grants.
|
|
84
|
+
|
|
85
|
+
UI access rules:
|
|
86
|
+
|
|
87
|
+
- Loopback (127.0.0.1 / ::1) is allowed without a token.
|
|
88
|
+
- Non-loopback requires a token via `?token=` or `X-OpenClaw-Token` / `Authorization: Bearer`.
|
|
89
|
+
|
|
90
|
+
Policy management:
|
|
91
|
+
|
|
92
|
+
- The UI includes a Cedar policy panel for CRUD.
|
|
93
|
+
- If `policyStoreUri` is set, policies are read-only.
|
|
94
|
+
- Default policy set is empty, so all tool calls are denied unless you add permit policies.
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Clawclamp
|
|
2
|
+
|
|
3
|
+
[English README](./README.md)
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
Clawclamp 是一个为 OpenClaw 提供 Cedar 权限控制的插件。它会在每次工具调用前执行 Cedar 鉴权,记录允许/拒绝审计日志,并提供一个网关 UI 用于查看日志、维护 Cedar policy 和发放短期授权。
|
|
11
|
+
|
|
12
|
+
这个仓库属于 vibe coding 项目,主要实现内容由 AI 辅助生成。
|
|
13
|
+
|
|
14
|
+
## 截图
|
|
15
|
+
|
|
16
|
+
准备发布到 GitHub 时,建议把截图放到 `screenshots/` 目录,并在这里引用。
|
|
17
|
+
|
|
18
|
+
- 建议文件名:`screenshots/policy-lab.png`、`screenshots/audit-log.png`
|
|
19
|
+
- 建议展示内容:策略编辑器、审计日志表格、模式切换和短期授权区域
|
|
20
|
+
|
|
21
|
+
## 功能
|
|
22
|
+
|
|
23
|
+
- 基于 `before_tool_call` 的 Cedar 鉴权
|
|
24
|
+
- 基于 Cedar policy 的长期授权控制
|
|
25
|
+
- 基于带过期时间的 Cedar policy 的短期授权
|
|
26
|
+
- 审计 UI,可查看允许、拒绝和灰度放行的工具调用
|
|
27
|
+
- 灰度模式:即使被 Cedar 拒绝,调用也可继续执行,但会被审计记录
|
|
28
|
+
|
|
29
|
+
## 安装
|
|
30
|
+
|
|
31
|
+
通过 npm 安装:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
openclaw plugins install @plusplus7/clawclamp
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
如果通过配置或插件管理安装,包名使用 `@plusplus7/clawclamp`。
|
|
38
|
+
|
|
39
|
+
## GitHub 展示文案
|
|
40
|
+
|
|
41
|
+
建议仓库描述:
|
|
42
|
+
|
|
43
|
+
> OpenClaw 的 Cedar 权限控制与审计插件,一个 vibe coding / AI 辅助生成项目。
|
|
44
|
+
|
|
45
|
+
建议 topics:
|
|
46
|
+
|
|
47
|
+
`openclaw`、`cedar`、`authorization`、`audit`、`plugin`、`ai-generated`、`vibe-coding`
|
|
48
|
+
|
|
49
|
+
## 配置
|
|
50
|
+
|
|
51
|
+
在 `plugins.entries.clawclamp.config` 下配置:
|
|
52
|
+
|
|
53
|
+
```yaml
|
|
54
|
+
plugins:
|
|
55
|
+
entries:
|
|
56
|
+
clawclamp:
|
|
57
|
+
enabled: true
|
|
58
|
+
config:
|
|
59
|
+
mode: gray
|
|
60
|
+
principalId: openclaw
|
|
61
|
+
policyStoreUri: file:///path/to/policy-store.json
|
|
62
|
+
policyFailOpen: false
|
|
63
|
+
# 可选:UI 访问令牌(非 loopback 时可通过 ?token= 或 X-OpenClaw-Token 访问)
|
|
64
|
+
# uiToken: "your-ui-token"
|
|
65
|
+
risk:
|
|
66
|
+
default: high
|
|
67
|
+
overrides:
|
|
68
|
+
read: low
|
|
69
|
+
web_search: medium
|
|
70
|
+
exec: high
|
|
71
|
+
grants:
|
|
72
|
+
defaultTtlSeconds: 900
|
|
73
|
+
maxTtlSeconds: 3600
|
|
74
|
+
audit:
|
|
75
|
+
maxEntries: 500
|
|
76
|
+
includeParams: true
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`policyStoreUri` 指向一个 Cedar policy store JSON(`file://` 或 `https://`)。也可以使用 `policyStoreLocal` 直接传入原始 JSON 字符串。如果不配置,插件会使用内置的默认 policy store。默认情况下不会自动放行任何工具,只有显式 permit policy 或短期授权 policy 才能放行。
|
|
80
|
+
|
|
81
|
+
## UI
|
|
82
|
+
|
|
83
|
+
打开网关路径 `/plugins/clawclamp`,可以查看审计日志、切换模式、创建短期授权,以及增删改查 Cedar policy。
|
|
84
|
+
|
|
85
|
+
UI 访问规则:
|
|
86
|
+
|
|
87
|
+
- 来自 loopback(`127.0.0.1` / `::1`)的请求默认允许访问
|
|
88
|
+
- 非 loopback 请求需要通过 `?token=`、`X-OpenClaw-Token` 或 `Authorization: Bearer` 提供令牌
|
|
89
|
+
|
|
90
|
+
策略管理说明:
|
|
91
|
+
|
|
92
|
+
- UI 内置 Cedar policy 面板,支持 CRUD
|
|
93
|
+
- 如果配置了 `policyStoreUri`,策略将变为只读
|
|
94
|
+
- 默认 policy 集为空,因此默认拒绝所有工具调用,除非你手动添加 permit policy 或创建短期授权
|
package/RELEASE.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Release Checklist
|
|
2
|
+
|
|
3
|
+
## Before Publish
|
|
4
|
+
|
|
5
|
+
1. Verify package name in `package.json` is `@dejavu/clawclamp`
|
|
6
|
+
2. Run tests
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pnpm exec vitest run
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
3. Check README links and screenshots
|
|
13
|
+
4. Commit release changes
|
|
14
|
+
|
|
15
|
+
## Publish To npm
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm login
|
|
19
|
+
npm publish --access public
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Publish To GitHub
|
|
23
|
+
|
|
24
|
+
1. Create repository
|
|
25
|
+
2. Push code
|
|
26
|
+
3. Add repository description:
|
|
27
|
+
|
|
28
|
+
> Cedar-based authorization and audit plugin for OpenClaw, built as a vibe-coding / AI-assisted project.
|
|
29
|
+
|
|
30
|
+
4. Add topics:
|
|
31
|
+
|
|
32
|
+
`openclaw`, `cedar`, `authorization`, `audit`, `plugin`, `ai-generated`, `vibe-coding`
|
|
33
|
+
|
|
34
|
+
5. Add screenshots under `screenshots/`
|
package/assets/app.js
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
const API_BASE = "/plugins/clawclamp/api";
|
|
2
|
+
|
|
3
|
+
const qs = (id) => document.getElementById(id);
|
|
4
|
+
|
|
5
|
+
const modeEl = qs("mode");
|
|
6
|
+
const modeNoteEl = qs("mode-note");
|
|
7
|
+
const enforceBtn = qs("mode-enforce");
|
|
8
|
+
const grayBtn = qs("mode-gray");
|
|
9
|
+
const refreshBtn = qs("refresh");
|
|
10
|
+
const grantForm = qs("grant-form");
|
|
11
|
+
const grantToolInput = qs("grant-tool");
|
|
12
|
+
const grantTtlInput = qs("grant-ttl");
|
|
13
|
+
const grantNoteInput = qs("grant-note");
|
|
14
|
+
const grantHint = qs("grant-hint");
|
|
15
|
+
const grantsEl = qs("grants");
|
|
16
|
+
const auditEl = qs("audit");
|
|
17
|
+
const auditPageSizeEl = qs("audit-page-size");
|
|
18
|
+
const auditPrevEl = qs("audit-prev");
|
|
19
|
+
const auditNextEl = qs("audit-next");
|
|
20
|
+
const auditPageInfoEl = qs("audit-page-info");
|
|
21
|
+
const policyListEl = qs("policy-list");
|
|
22
|
+
const policyIdInput = qs("policy-id");
|
|
23
|
+
const policyContentInput = qs("policy-content");
|
|
24
|
+
const policyCreateBtn = qs("policy-create");
|
|
25
|
+
const policyUpdateBtn = qs("policy-update");
|
|
26
|
+
const policyDeleteBtn = qs("policy-delete");
|
|
27
|
+
const policyStatusEl = qs("policy-status");
|
|
28
|
+
let policyReadOnly = false;
|
|
29
|
+
let policies = [];
|
|
30
|
+
let auditPage = 1;
|
|
31
|
+
let auditPageSize = Number(auditPageSizeEl?.value || 50);
|
|
32
|
+
let auditTotal = 0;
|
|
33
|
+
|
|
34
|
+
async function fetchJson(path, opts = {}) {
|
|
35
|
+
const res = await fetch(path, {
|
|
36
|
+
headers: { "content-type": "application/json" },
|
|
37
|
+
...opts,
|
|
38
|
+
});
|
|
39
|
+
const payload = await res.json().catch(() => ({}));
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
const message = payload.error || `Request failed (${res.status})`;
|
|
42
|
+
throw new Error(message);
|
|
43
|
+
}
|
|
44
|
+
return payload;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function formatTime(iso) {
|
|
48
|
+
if (!iso) return "-";
|
|
49
|
+
const date = new Date(iso);
|
|
50
|
+
return date.toLocaleTimeString();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function renderMode(state) {
|
|
54
|
+
modeEl.textContent = state.mode === "gray" ? "灰度" : "强制";
|
|
55
|
+
modeNoteEl.textContent =
|
|
56
|
+
state.mode === "gray"
|
|
57
|
+
? "被拒绝的工具调用仍会执行,但会标记为灰度放行。"
|
|
58
|
+
: "被拒绝的工具调用将被阻断。";
|
|
59
|
+
enforceBtn.disabled = state.mode === "enforce";
|
|
60
|
+
grayBtn.disabled = state.mode === "gray";
|
|
61
|
+
grantHint.textContent =
|
|
62
|
+
`默认 TTL ${state.grants.defaultTtlSeconds}s,最长 ${state.grants.maxTtlSeconds}s。`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function renderGrants(grants) {
|
|
66
|
+
if (!grants.length) {
|
|
67
|
+
grantsEl.innerHTML = "<div class=\"note\">暂无有效授权。</div>";
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
grantsEl.innerHTML = "";
|
|
71
|
+
grants.forEach((grant) => {
|
|
72
|
+
const item = document.createElement("div");
|
|
73
|
+
item.className = "list-item";
|
|
74
|
+
item.innerHTML = `
|
|
75
|
+
<div>
|
|
76
|
+
<strong>${grant.toolName}</strong><br />
|
|
77
|
+
<span class="note">到期时间 ${new Date(grant.expiresAt).toLocaleString()}</span>
|
|
78
|
+
</div>
|
|
79
|
+
<button class="btn ghost" data-id="${grant.id}">撤销</button>
|
|
80
|
+
`;
|
|
81
|
+
item.querySelector("button").addEventListener("click", async () => {
|
|
82
|
+
await fetchJson(`${API_BASE}/grants/${grant.id}`, { method: "DELETE" });
|
|
83
|
+
await refreshAll();
|
|
84
|
+
});
|
|
85
|
+
grantsEl.appendChild(item);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function decisionBadge(decision) {
|
|
90
|
+
if (decision === "allow_grayed") return "gray";
|
|
91
|
+
if (decision === "deny") return "deny";
|
|
92
|
+
return "allow";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function decisionLabel(decision) {
|
|
96
|
+
if (decision === "allow_grayed") return "灰度放行";
|
|
97
|
+
if (decision === "deny") return "拒绝";
|
|
98
|
+
if (decision === "error") return "错误";
|
|
99
|
+
return "允许";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function riskLabel(risk) {
|
|
103
|
+
if (risk === "low") return "低";
|
|
104
|
+
if (risk === "medium") return "中";
|
|
105
|
+
if (risk === "high") return "高";
|
|
106
|
+
return risk || "-";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function renderAudit(entries) {
|
|
110
|
+
if (!entries.length) {
|
|
111
|
+
auditEl.innerHTML = "<div class=\"note\">暂无审计记录。</div>";
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const header = document.createElement("div");
|
|
116
|
+
header.className = "table-row header";
|
|
117
|
+
header.innerHTML =
|
|
118
|
+
"<div>时间</div><div>工具</div><div>决策</div><div>风险</div><div>说明</div><div>操作</div>";
|
|
119
|
+
|
|
120
|
+
auditEl.innerHTML = "";
|
|
121
|
+
auditEl.appendChild(header);
|
|
122
|
+
|
|
123
|
+
entries.forEach((entry) => {
|
|
124
|
+
const row = document.createElement("div");
|
|
125
|
+
row.className = "table-row";
|
|
126
|
+
const badgeClass = decisionBadge(entry.decision);
|
|
127
|
+
const canAllow = entry.decision === "deny" || entry.decision === "allow_grayed";
|
|
128
|
+
const canDeny = entry.decision === "allow";
|
|
129
|
+
const paramsText =
|
|
130
|
+
entry.params && typeof entry.params === "object"
|
|
131
|
+
? JSON.stringify(entry.params, null, 2)
|
|
132
|
+
: entry.params || "";
|
|
133
|
+
const meta = [
|
|
134
|
+
entry.toolCallId ? `toolCallId: ${entry.toolCallId}` : null,
|
|
135
|
+
entry.runId ? `runId: ${entry.runId}` : null,
|
|
136
|
+
entry.sessionKey ? `sessionKey: ${entry.sessionKey}` : null,
|
|
137
|
+
entry.agentId ? `agentId: ${entry.agentId}` : null,
|
|
138
|
+
].filter(Boolean);
|
|
139
|
+
row.innerHTML = `
|
|
140
|
+
<div>${formatTime(entry.timestamp)}</div>
|
|
141
|
+
<div class="mono">${entry.toolName}</div>
|
|
142
|
+
<div class="badge ${badgeClass}">${decisionLabel(entry.decision)}</div>
|
|
143
|
+
<div>${riskLabel(entry.risk)}</div>
|
|
144
|
+
<div>${entry.reason || entry.error || ""}</div>
|
|
145
|
+
<div class="actions">
|
|
146
|
+
${canAllow ? "<button class=\"btn mini\" data-action=\"allow\">一键允许</button>" : ""}
|
|
147
|
+
${canDeny ? "<button class=\"btn mini warn\" data-action=\"deny\">一键拒绝</button>" : ""}
|
|
148
|
+
</div>
|
|
149
|
+
<div class="audit-detail">
|
|
150
|
+
<div class="audit-meta">${meta.join(" · ")}</div>
|
|
151
|
+
${paramsText ? `<pre>${paramsText}</pre>` : ""}
|
|
152
|
+
</div>
|
|
153
|
+
`;
|
|
154
|
+
const allowBtn = row.querySelector("button[data-action=\"allow\"]");
|
|
155
|
+
if (allowBtn) {
|
|
156
|
+
allowBtn.addEventListener("click", async () => {
|
|
157
|
+
await applyPolicyChange("permit", entry.toolName);
|
|
158
|
+
await refreshAll();
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
const denyBtn = row.querySelector("button[data-action=\"deny\"]");
|
|
162
|
+
if (denyBtn) {
|
|
163
|
+
denyBtn.addEventListener("click", async () => {
|
|
164
|
+
await applyPolicyChange("forbid", entry.toolName);
|
|
165
|
+
await refreshAll();
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
auditEl.appendChild(row);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function renderAuditPager(total, page, pageSize) {
|
|
173
|
+
auditTotal = total || 0;
|
|
174
|
+
auditPage = page || 1;
|
|
175
|
+
const totalPages = Math.max(1, Math.ceil(auditTotal / pageSize));
|
|
176
|
+
auditPageInfoEl.textContent = `第 ${auditPage} / ${totalPages} 页 · 共 ${auditTotal} 条`;
|
|
177
|
+
auditPrevEl.disabled = auditPage <= 1;
|
|
178
|
+
auditNextEl.disabled = auditPage >= totalPages;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function renderPolicyList(list) {
|
|
182
|
+
policies = list;
|
|
183
|
+
if (!list.length) {
|
|
184
|
+
policyListEl.innerHTML = "<div class=\"note\">暂无策略。</div>";
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
policyListEl.innerHTML = "";
|
|
188
|
+
list.forEach((policy) => {
|
|
189
|
+
const item = document.createElement("button");
|
|
190
|
+
item.className = "policy-item";
|
|
191
|
+
item.textContent = policy.id;
|
|
192
|
+
item.addEventListener("click", () => {
|
|
193
|
+
policyIdInput.value = policy.id;
|
|
194
|
+
policyContentInput.value = policy.content || "";
|
|
195
|
+
policyStatusEl.textContent = "";
|
|
196
|
+
});
|
|
197
|
+
policyListEl.appendChild(item);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function refreshPolicies() {
|
|
202
|
+
const result = await fetchJson(`${API_BASE}/policies`);
|
|
203
|
+
policyReadOnly = result.readOnly === true;
|
|
204
|
+
renderPolicyList(result.policies || []);
|
|
205
|
+
policyCreateBtn.disabled = policyReadOnly;
|
|
206
|
+
policyUpdateBtn.disabled = policyReadOnly;
|
|
207
|
+
policyDeleteBtn.disabled = policyReadOnly;
|
|
208
|
+
if (policyReadOnly) {
|
|
209
|
+
policyStatusEl.textContent = "policyStoreUri 模式下为只读。";
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function refreshAll() {
|
|
214
|
+
const state = await fetchJson(`${API_BASE}/state`);
|
|
215
|
+
renderMode(state);
|
|
216
|
+
const grants = await fetchJson(`${API_BASE}/grants`);
|
|
217
|
+
renderGrants(grants.grants || []);
|
|
218
|
+
const logs = await fetchJson(`${API_BASE}/logs?page=${auditPage}&pageSize=${auditPageSize}`);
|
|
219
|
+
renderAudit(logs.entries || []);
|
|
220
|
+
renderAuditPager(logs.total || 0, logs.page || auditPage, logs.pageSize || auditPageSize);
|
|
221
|
+
await refreshPolicies();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
refreshBtn.addEventListener("click", () => {
|
|
225
|
+
refreshAll();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
enforceBtn.addEventListener("click", async () => {
|
|
229
|
+
await fetchJson(`${API_BASE}/mode`, {
|
|
230
|
+
method: "POST",
|
|
231
|
+
body: JSON.stringify({ mode: "enforce" }),
|
|
232
|
+
});
|
|
233
|
+
await refreshAll();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
grayBtn.addEventListener("click", async () => {
|
|
237
|
+
await fetchJson(`${API_BASE}/mode`, {
|
|
238
|
+
method: "POST",
|
|
239
|
+
body: JSON.stringify({ mode: "gray" }),
|
|
240
|
+
});
|
|
241
|
+
await refreshAll();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
grantForm.addEventListener("submit", async (event) => {
|
|
245
|
+
event.preventDefault();
|
|
246
|
+
const toolName = grantToolInput.value.trim();
|
|
247
|
+
const ttlSeconds = grantTtlInput.value ? Number(grantTtlInput.value) : undefined;
|
|
248
|
+
const note = grantNoteInput.value.trim();
|
|
249
|
+
await fetchJson(`${API_BASE}/grants`, {
|
|
250
|
+
method: "POST",
|
|
251
|
+
body: JSON.stringify({ toolName, ttlSeconds, note: note || undefined }),
|
|
252
|
+
});
|
|
253
|
+
grantToolInput.value = "";
|
|
254
|
+
grantTtlInput.value = "";
|
|
255
|
+
grantNoteInput.value = "";
|
|
256
|
+
await refreshAll();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
auditPageSizeEl.addEventListener("change", async () => {
|
|
260
|
+
auditPageSize = Number(auditPageSizeEl.value || 50);
|
|
261
|
+
auditPage = 1;
|
|
262
|
+
await refreshAll();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
auditPrevEl.addEventListener("click", async () => {
|
|
266
|
+
if (auditPage <= 1) return;
|
|
267
|
+
auditPage -= 1;
|
|
268
|
+
await refreshAll();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
auditNextEl.addEventListener("click", async () => {
|
|
272
|
+
const totalPages = Math.max(1, Math.ceil(auditTotal / auditPageSize));
|
|
273
|
+
if (auditPage >= totalPages) return;
|
|
274
|
+
auditPage += 1;
|
|
275
|
+
await refreshAll();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
refreshAll();
|
|
279
|
+
setInterval(refreshAll, 10_000);
|
|
280
|
+
|
|
281
|
+
function sanitizeIdPart(value) {
|
|
282
|
+
return value.toLowerCase().replace(/[^a-z0-9_-]+/g, "_").slice(0, 64);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function policyIdPrefix(effect, toolName) {
|
|
286
|
+
return `ui-${effect}:${sanitizeIdPart(toolName)}:`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function policyBody(effect, toolName) {
|
|
290
|
+
const keyword = effect === "forbid" ? "forbid" : "permit";
|
|
291
|
+
return `${keyword}(principal, action, resource)\nwhen {\n action == Action::\"Invoke\" && resource.name == \"${toolName}\"\n};`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function removePoliciesByPrefix(prefix) {
|
|
295
|
+
const targets = policies.filter((policy) => policy.id.startsWith(prefix));
|
|
296
|
+
for (const policy of targets) {
|
|
297
|
+
await fetchJson(`${API_BASE}/policies/${encodeURIComponent(policy.id)}`, {
|
|
298
|
+
method: "DELETE",
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function applyPolicyChange(effect, toolName) {
|
|
304
|
+
const opposite = effect === "permit" ? "forbid" : "permit";
|
|
305
|
+
await removePoliciesByPrefix(policyIdPrefix(opposite, toolName));
|
|
306
|
+
const id = `${policyIdPrefix(effect, toolName)}${Date.now()}`;
|
|
307
|
+
const content = policyBody(effect, toolName);
|
|
308
|
+
await fetchJson(`${API_BASE}/policies`, {
|
|
309
|
+
method: "POST",
|
|
310
|
+
body: JSON.stringify({ id, content }),
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
policyCreateBtn.addEventListener("click", async () => {
|
|
315
|
+
const id = policyIdInput.value.trim();
|
|
316
|
+
const content = policyContentInput.value.trim();
|
|
317
|
+
if (!content) {
|
|
318
|
+
policyStatusEl.textContent = "请输入 Policy 内容。";
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const body = { content, ...(id ? { id } : {}) };
|
|
322
|
+
await fetchJson(`${API_BASE}/policies`, {
|
|
323
|
+
method: "POST",
|
|
324
|
+
body: JSON.stringify(body),
|
|
325
|
+
});
|
|
326
|
+
policyStatusEl.textContent = "已新增策略。";
|
|
327
|
+
await refreshAll();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
policyUpdateBtn.addEventListener("click", async () => {
|
|
331
|
+
const id = policyIdInput.value.trim();
|
|
332
|
+
const content = policyContentInput.value.trim();
|
|
333
|
+
if (!id) {
|
|
334
|
+
policyStatusEl.textContent = "请选择或填写 Policy ID。";
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (!content) {
|
|
338
|
+
policyStatusEl.textContent = "请输入 Policy 内容。";
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
await fetchJson(`${API_BASE}/policies/${encodeURIComponent(id)}`, {
|
|
342
|
+
method: "PUT",
|
|
343
|
+
body: JSON.stringify({ content }),
|
|
344
|
+
});
|
|
345
|
+
policyStatusEl.textContent = "已保存策略。";
|
|
346
|
+
await refreshAll();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
policyDeleteBtn.addEventListener("click", async () => {
|
|
350
|
+
const id = policyIdInput.value.trim();
|
|
351
|
+
if (!id) {
|
|
352
|
+
policyStatusEl.textContent = "请选择或填写 Policy ID。";
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
await fetchJson(`${API_BASE}/policies/${encodeURIComponent(id)}`, {
|
|
356
|
+
method: "DELETE",
|
|
357
|
+
});
|
|
358
|
+
policyStatusEl.textContent = "已删除策略。";
|
|
359
|
+
policyIdInput.value = "";
|
|
360
|
+
policyContentInput.value = "";
|
|
361
|
+
await refreshAll();
|
|
362
|
+
});
|