clawclamp 0.1.13 → 0.1.14
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/assets/app.js +37 -2
- package/assets/index.html +16 -0
- package/assets/styles.css +27 -1
- package/package.json +1 -1
- package/src/audit.ts +44 -5
- package/src/cedarling.ts +4 -0
- package/src/grants.ts +137 -56
- package/src/guard.ts +11 -18
- package/src/http.ts +12 -5
- package/src/policy.ts +1 -8
package/assets/app.js
CHANGED
|
@@ -14,6 +14,10 @@ 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 auditPageSizeEl = qs("audit-page-size");
|
|
18
|
+
const auditPrevEl = qs("audit-prev");
|
|
19
|
+
const auditNextEl = qs("audit-next");
|
|
20
|
+
const auditPageInfoEl = qs("audit-page-info");
|
|
17
21
|
const policyListEl = qs("policy-list");
|
|
18
22
|
const policyIdInput = qs("policy-id");
|
|
19
23
|
const policyContentInput = qs("policy-content");
|
|
@@ -23,7 +27,9 @@ const policyDeleteBtn = qs("policy-delete");
|
|
|
23
27
|
const policyStatusEl = qs("policy-status");
|
|
24
28
|
let policyReadOnly = false;
|
|
25
29
|
let policies = [];
|
|
26
|
-
let
|
|
30
|
+
let auditPage = 1;
|
|
31
|
+
let auditPageSize = Number(auditPageSizeEl?.value || 50);
|
|
32
|
+
let auditTotal = 0;
|
|
27
33
|
|
|
28
34
|
async function fetchJson(path, opts = {}) {
|
|
29
35
|
const res = await fetch(path, {
|
|
@@ -163,6 +169,15 @@ function renderAudit(entries) {
|
|
|
163
169
|
});
|
|
164
170
|
}
|
|
165
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
|
+
|
|
166
181
|
function renderPolicyList(list) {
|
|
167
182
|
policies = list;
|
|
168
183
|
if (!list.length) {
|
|
@@ -200,8 +215,9 @@ async function refreshAll() {
|
|
|
200
215
|
renderMode(state);
|
|
201
216
|
const grants = await fetchJson(`${API_BASE}/grants`);
|
|
202
217
|
renderGrants(grants.grants || []);
|
|
203
|
-
const logs = await fetchJson(`${API_BASE}/logs`);
|
|
218
|
+
const logs = await fetchJson(`${API_BASE}/logs?page=${auditPage}&pageSize=${auditPageSize}`);
|
|
204
219
|
renderAudit(logs.entries || []);
|
|
220
|
+
renderAuditPager(logs.total || 0, logs.page || auditPage, logs.pageSize || auditPageSize);
|
|
205
221
|
await refreshPolicies();
|
|
206
222
|
}
|
|
207
223
|
|
|
@@ -240,6 +256,25 @@ grantForm.addEventListener("submit", async (event) => {
|
|
|
240
256
|
await refreshAll();
|
|
241
257
|
});
|
|
242
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
|
+
|
|
243
278
|
refreshAll();
|
|
244
279
|
setInterval(refreshAll, 10_000);
|
|
245
280
|
|
package/assets/index.html
CHANGED
|
@@ -66,6 +66,22 @@
|
|
|
66
66
|
<section class="card">
|
|
67
67
|
<h2>审计日志</h2>
|
|
68
68
|
<div class="note">最近的工具调用记录(允许、拒绝、灰度放行)。</div>
|
|
69
|
+
<div class="toolbar">
|
|
70
|
+
<label class="toolbar-item">
|
|
71
|
+
每页
|
|
72
|
+
<select id="audit-page-size">
|
|
73
|
+
<option value="20">20</option>
|
|
74
|
+
<option value="50" selected>50</option>
|
|
75
|
+
<option value="100">100</option>
|
|
76
|
+
<option value="200">200</option>
|
|
77
|
+
</select>
|
|
78
|
+
</label>
|
|
79
|
+
<div class="toolbar-item pagination">
|
|
80
|
+
<button id="audit-prev" class="btn">上一页</button>
|
|
81
|
+
<span id="audit-page-info" class="note">第 1 页</span>
|
|
82
|
+
<button id="audit-next" class="btn">下一页</button>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
69
85
|
<div class="table" id="audit"></div>
|
|
70
86
|
</section>
|
|
71
87
|
|
package/assets/styles.css
CHANGED
|
@@ -119,6 +119,31 @@ h2 {
|
|
|
119
119
|
margin-top: 6px;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
.toolbar {
|
|
123
|
+
display: flex;
|
|
124
|
+
align-items: center;
|
|
125
|
+
justify-content: space-between;
|
|
126
|
+
gap: 10px;
|
|
127
|
+
margin-top: 10px;
|
|
128
|
+
flex-wrap: wrap;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.toolbar-item {
|
|
132
|
+
display: flex;
|
|
133
|
+
align-items: center;
|
|
134
|
+
gap: 8px;
|
|
135
|
+
font-size: 0.82rem;
|
|
136
|
+
color: var(--muted);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.toolbar-item select {
|
|
140
|
+
min-width: 76px;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.pagination {
|
|
144
|
+
margin-left: auto;
|
|
145
|
+
}
|
|
146
|
+
|
|
122
147
|
.btn {
|
|
123
148
|
padding: 8px 12px;
|
|
124
149
|
border-radius: 8px;
|
|
@@ -171,7 +196,8 @@ h2 {
|
|
|
171
196
|
color: var(--muted);
|
|
172
197
|
}
|
|
173
198
|
|
|
174
|
-
input
|
|
199
|
+
input,
|
|
200
|
+
select {
|
|
175
201
|
border: 1px solid var(--border);
|
|
176
202
|
border-radius: 8px;
|
|
177
203
|
padding: 8px 10px;
|
package/package.json
CHANGED
package/src/audit.ts
CHANGED
|
@@ -5,11 +5,17 @@ import type { AuditEntry } from "./types.js";
|
|
|
5
5
|
import { withStateFileLock } from "./storage.js";
|
|
6
6
|
|
|
7
7
|
const AUDIT_FILE = "audit.jsonl";
|
|
8
|
+
const AUDIT_ROTATE_MAX_LINES = 5000;
|
|
9
|
+
const AUDIT_ROTATE_KEEP_LINES = 2500;
|
|
8
10
|
|
|
9
11
|
function resolveAuditPath(stateDir: string): string {
|
|
10
12
|
return path.join(stateDir, "clawclamp", AUDIT_FILE);
|
|
11
13
|
}
|
|
12
14
|
|
|
15
|
+
function resolveAuditArchivePath(stateDir: string, timestamp: string): string {
|
|
16
|
+
return path.join(stateDir, "clawclamp", `audit-${timestamp}.jsonl`);
|
|
17
|
+
}
|
|
18
|
+
|
|
13
19
|
export function createAuditEntryId(): string {
|
|
14
20
|
return randomUUID();
|
|
15
21
|
}
|
|
@@ -19,10 +25,38 @@ export async function appendAuditEntry(stateDir: string, entry: AuditEntry): Pro
|
|
|
19
25
|
const filePath = resolveAuditPath(stateDir);
|
|
20
26
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
21
27
|
await fs.appendFile(filePath, `${JSON.stringify(entry)}\n`, "utf8");
|
|
28
|
+
await rotateAuditLogIfNeeded(stateDir, filePath);
|
|
22
29
|
});
|
|
23
30
|
}
|
|
24
31
|
|
|
25
|
-
|
|
32
|
+
async function rotateAuditLogIfNeeded(stateDir: string, filePath: string): Promise<void> {
|
|
33
|
+
let raw: string;
|
|
34
|
+
try {
|
|
35
|
+
raw = await fs.readFile(filePath, "utf8");
|
|
36
|
+
} catch (error) {
|
|
37
|
+
const code = (error as { code?: string }).code;
|
|
38
|
+
if (code === "ENOENT") {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
const lines = raw.split("\n").filter(Boolean);
|
|
44
|
+
if (lines.length <= AUDIT_ROTATE_MAX_LINES) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const cutoff = Math.max(0, lines.length - AUDIT_ROTATE_KEEP_LINES);
|
|
48
|
+
const archived = lines.slice(0, cutoff);
|
|
49
|
+
const current = lines.slice(cutoff);
|
|
50
|
+
const archivePath = resolveAuditArchivePath(stateDir, new Date().toISOString().replace(/[:.]/g, "-"));
|
|
51
|
+
await fs.writeFile(archivePath, `${archived.join("\n")}\n`, "utf8");
|
|
52
|
+
await fs.writeFile(filePath, current.length ? `${current.join("\n")}\n` : "", "utf8");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function readAuditEntries(
|
|
56
|
+
stateDir: string,
|
|
57
|
+
page: number,
|
|
58
|
+
pageSize: number,
|
|
59
|
+
): Promise<{ entries: AuditEntry[]; total: number; page: number }> {
|
|
26
60
|
return withStateFileLock(stateDir, "audit", async () => {
|
|
27
61
|
const filePath = resolveAuditPath(stateDir);
|
|
28
62
|
let raw: string;
|
|
@@ -31,14 +65,19 @@ export async function readAuditEntries(stateDir: string, limit: number): Promise
|
|
|
31
65
|
} catch (error) {
|
|
32
66
|
const code = (error as { code?: string }).code;
|
|
33
67
|
if (code === "ENOENT") {
|
|
34
|
-
return [];
|
|
68
|
+
return { entries: [], total: 0, page: 1 };
|
|
35
69
|
}
|
|
36
70
|
throw error;
|
|
37
71
|
}
|
|
38
72
|
|
|
39
73
|
const lines = raw.trim().split("\n").filter(Boolean);
|
|
40
|
-
const
|
|
41
|
-
const
|
|
74
|
+
const total = lines.length;
|
|
75
|
+
const safePageSize = Math.max(1, pageSize);
|
|
76
|
+
const totalPages = Math.max(1, Math.ceil(total / safePageSize));
|
|
77
|
+
const safePage = Math.min(Math.max(1, page), totalPages);
|
|
78
|
+
const start = Math.max(0, total - safePage * safePageSize);
|
|
79
|
+
const end = total - (safePage - 1) * safePageSize;
|
|
80
|
+
const recent = lines.slice(start, end);
|
|
42
81
|
const entries: AuditEntry[] = [];
|
|
43
82
|
for (const line of recent) {
|
|
44
83
|
try {
|
|
@@ -50,6 +89,6 @@ export async function readAuditEntries(stateDir: string, limit: number): Promise
|
|
|
50
89
|
// Ignore malformed lines.
|
|
51
90
|
}
|
|
52
91
|
}
|
|
53
|
-
return entries;
|
|
92
|
+
return { entries, total, page: safePage };
|
|
54
93
|
});
|
|
55
94
|
}
|
package/src/cedarling.ts
CHANGED
|
@@ -24,6 +24,10 @@ export async function getCedarling(config: CedarlingConfig): Promise<CedarlingIn
|
|
|
24
24
|
return cedarlingPromise;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
export function resetCedarlingInstance(): void {
|
|
28
|
+
cedarlingPromise = null;
|
|
29
|
+
}
|
|
30
|
+
|
|
27
31
|
async function ensureWasmInitialized(): Promise<void> {
|
|
28
32
|
if (!wasmInitPromise) {
|
|
29
33
|
wasmInitPromise = (async () => {
|
package/src/grants.ts
CHANGED
|
@@ -4,48 +4,134 @@ import { randomUUID } from "node:crypto";
|
|
|
4
4
|
import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk";
|
|
5
5
|
import type { ClawClampConfig, GrantRecord } from "./types.js";
|
|
6
6
|
import { withStateFileLock } from "./storage.js";
|
|
7
|
+
import { buildDefaultPolicyStore } from "./policy.js";
|
|
7
8
|
|
|
8
|
-
const
|
|
9
|
+
const POLICY_FILE = "policy-store.json";
|
|
10
|
+
const POLICY_STORE_ID = "clawclamp";
|
|
11
|
+
const GRANT_POLICY_PREFIX = "grant:";
|
|
9
12
|
|
|
10
|
-
type
|
|
11
|
-
|
|
13
|
+
type PolicyRecord = {
|
|
14
|
+
cedar_version?: string;
|
|
15
|
+
name?: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
policy_content: string;
|
|
12
18
|
};
|
|
13
19
|
|
|
14
|
-
|
|
20
|
+
type PolicyStoreBody = {
|
|
21
|
+
name?: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
schema?: unknown;
|
|
24
|
+
trusted_issuers?: Record<string, unknown>;
|
|
25
|
+
policies?: Record<string, PolicyRecord>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type PolicyStoreSnapshot = {
|
|
29
|
+
cedar_version: string;
|
|
30
|
+
policy_stores: Record<string, PolicyStoreBody>;
|
|
31
|
+
};
|
|
15
32
|
|
|
16
|
-
function
|
|
17
|
-
return path.join(stateDir, "clawclamp",
|
|
33
|
+
function resolvePolicyPath(stateDir: string): string {
|
|
34
|
+
return path.join(stateDir, "clawclamp", POLICY_FILE);
|
|
18
35
|
}
|
|
19
36
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
37
|
+
function encodeBase64(value: string): string {
|
|
38
|
+
return Buffer.from(value, "utf8").toString("base64");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function decodeBase64(value: string): string {
|
|
42
|
+
return Buffer.from(value, "base64").toString("utf8");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function readPolicyStore(stateDir: string): Promise<PolicyStoreSnapshot> {
|
|
46
|
+
const filePath = resolvePolicyPath(stateDir);
|
|
47
|
+
const { value } = await readJsonFileWithFallback<PolicyStoreSnapshot>(
|
|
48
|
+
filePath,
|
|
49
|
+
buildDefaultPolicyStore() as PolicyStoreSnapshot,
|
|
50
|
+
);
|
|
51
|
+
return value;
|
|
28
52
|
}
|
|
29
53
|
|
|
30
|
-
async function
|
|
31
|
-
const filePath =
|
|
54
|
+
async function writePolicyStore(stateDir: string, store: PolicyStoreSnapshot): Promise<void> {
|
|
55
|
+
const filePath = resolvePolicyPath(stateDir);
|
|
32
56
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
33
|
-
await writeJsonFileAtomically(filePath,
|
|
57
|
+
await writeJsonFileAtomically(filePath, store);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getWritableStore(store: PolicyStoreSnapshot): PolicyStoreBody {
|
|
61
|
+
const current = store.policy_stores?.[POLICY_STORE_ID];
|
|
62
|
+
if (!current) {
|
|
63
|
+
throw new Error("clawclamp policy store not found");
|
|
64
|
+
}
|
|
65
|
+
current.policies ??= {};
|
|
66
|
+
return current;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function grantPolicyId(createdAtMs: number): string {
|
|
70
|
+
return `${GRANT_POLICY_PREFIX}${createdAtMs}:${randomUUID()}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildGrantPolicy(toolName: string, expiresAtMs: number): string {
|
|
74
|
+
const toolClause =
|
|
75
|
+
toolName === "*" ? "true" : `resource.name == ${JSON.stringify(toolName)}`;
|
|
76
|
+
return `permit(principal, action, resource)
|
|
77
|
+
when {
|
|
78
|
+
action == Action::"Invoke" &&
|
|
79
|
+
context.now < ${expiresAtMs} &&
|
|
80
|
+
${toolClause}
|
|
81
|
+
};`;
|
|
34
82
|
}
|
|
35
83
|
|
|
36
|
-
function
|
|
37
|
-
|
|
84
|
+
function parseGrantPolicy(id: string, record: PolicyRecord): GrantRecord | null {
|
|
85
|
+
if (!id.startsWith(GRANT_POLICY_PREFIX)) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const content = decodeBase64(record.policy_content ?? "");
|
|
89
|
+
const createdAtMatch = /^grant:(\d+):/.exec(id);
|
|
90
|
+
const expiresAtMatch = /context\.now\s*<\s*(\d+)/.exec(content);
|
|
91
|
+
const toolMatch = /resource\.name\s*==\s*"([^"]+)"/.exec(content);
|
|
92
|
+
const createdAtMs = createdAtMatch ? Number(createdAtMatch[1]) : Date.now();
|
|
93
|
+
const expiresAtMs = expiresAtMatch ? Number(expiresAtMatch[1]) : 0;
|
|
94
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= 0) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
id,
|
|
99
|
+
toolName: toolMatch?.[1] ?? "*",
|
|
100
|
+
createdAt: new Date(createdAtMs).toISOString(),
|
|
101
|
+
expiresAt: new Date(expiresAtMs).toISOString(),
|
|
102
|
+
note: record.description?.trim() || undefined,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function sortNewestFirst(left: GrantRecord, right: GrantRecord): number {
|
|
107
|
+
return Date.parse(right.createdAt) - Date.parse(left.createdAt);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function pruneExpiredPolicies(policies: Record<string, PolicyRecord>, nowMs: number): boolean {
|
|
111
|
+
let changed = false;
|
|
112
|
+
for (const [id, record] of Object.entries(policies)) {
|
|
113
|
+
const grant = parseGrantPolicy(id, record);
|
|
114
|
+
if (grant && Date.parse(grant.expiresAt) <= nowMs) {
|
|
115
|
+
delete policies[id];
|
|
116
|
+
changed = true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return changed;
|
|
38
120
|
}
|
|
39
121
|
|
|
40
122
|
export async function listGrants(stateDir: string): Promise<GrantRecord[]> {
|
|
41
|
-
return withStateFileLock(stateDir, "
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
123
|
+
return withStateFileLock(stateDir, "policy-store", async () => {
|
|
124
|
+
const store = await readPolicyStore(stateDir);
|
|
125
|
+
const policyStore = getWritableStore(store);
|
|
126
|
+
const nowMs = Date.now();
|
|
127
|
+
const changed = pruneExpiredPolicies(policyStore.policies ?? {}, nowMs);
|
|
128
|
+
if (changed) {
|
|
129
|
+
await writePolicyStore(stateDir, store);
|
|
47
130
|
}
|
|
48
|
-
return
|
|
131
|
+
return Object.entries(policyStore.policies ?? {})
|
|
132
|
+
.map(([id, record]) => parseGrantPolicy(id, record))
|
|
133
|
+
.filter((grant): grant is GrantRecord => Boolean(grant))
|
|
134
|
+
.sort(sortNewestFirst);
|
|
49
135
|
});
|
|
50
136
|
}
|
|
51
137
|
|
|
@@ -56,47 +142,42 @@ export async function createGrant(params: {
|
|
|
56
142
|
ttlSeconds?: number;
|
|
57
143
|
note?: string;
|
|
58
144
|
}): Promise<GrantRecord> {
|
|
59
|
-
return withStateFileLock(params.stateDir, "
|
|
60
|
-
const
|
|
61
|
-
const
|
|
145
|
+
return withStateFileLock(params.stateDir, "policy-store", async () => {
|
|
146
|
+
const store = await readPolicyStore(params.stateDir);
|
|
147
|
+
const policyStore = getWritableStore(store);
|
|
148
|
+
const nowMs = Date.now();
|
|
62
149
|
const ttlSeconds = Math.min(
|
|
63
150
|
Math.max(60, params.ttlSeconds ?? params.config.grants.defaultTtlSeconds),
|
|
64
151
|
params.config.grants.maxTtlSeconds,
|
|
65
152
|
);
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
|
|
153
|
+
const expiresAtMs = nowMs + ttlSeconds * 1000;
|
|
154
|
+
const id = grantPolicyId(nowMs);
|
|
155
|
+
policyStore.policies![id] = {
|
|
156
|
+
cedar_version: store.cedar_version,
|
|
157
|
+
name: `Grant ${params.toolName}`,
|
|
158
|
+
description: params.note?.trim() || undefined,
|
|
159
|
+
policy_content: encodeBase64(buildGrantPolicy(params.toolName, expiresAtMs)),
|
|
160
|
+
};
|
|
161
|
+
await writePolicyStore(params.stateDir, store);
|
|
162
|
+
return {
|
|
163
|
+
id,
|
|
69
164
|
toolName: params.toolName,
|
|
70
|
-
createdAt:
|
|
71
|
-
expiresAt:
|
|
72
|
-
note: params.note?.trim()
|
|
165
|
+
createdAt: new Date(nowMs).toISOString(),
|
|
166
|
+
expiresAt: new Date(expiresAtMs).toISOString(),
|
|
167
|
+
note: params.note?.trim() || undefined,
|
|
73
168
|
};
|
|
74
|
-
const pruned = pruneExpired(state.grants, now);
|
|
75
|
-
pruned.push(grant);
|
|
76
|
-
await writeGrantState(params.stateDir, { grants: pruned });
|
|
77
|
-
return grant;
|
|
78
169
|
});
|
|
79
170
|
}
|
|
80
171
|
|
|
81
172
|
export async function revokeGrant(stateDir: string, grantId: string): Promise<boolean> {
|
|
82
|
-
return withStateFileLock(stateDir, "
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
if (changed) {
|
|
87
|
-
await writeGrantState(stateDir, { grants: next });
|
|
88
|
-
}
|
|
89
|
-
return changed;
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export function findActiveGrant(grants: GrantRecord[], toolName: string): GrantRecord | undefined {
|
|
94
|
-
const now = Date.now();
|
|
95
|
-
return grants.find((grant) => {
|
|
96
|
-
if (grant.toolName !== "*" && grant.toolName !== toolName) {
|
|
173
|
+
return withStateFileLock(stateDir, "policy-store", async () => {
|
|
174
|
+
const store = await readPolicyStore(stateDir);
|
|
175
|
+
const policyStore = getWritableStore(store);
|
|
176
|
+
if (!policyStore.policies?.[grantId]) {
|
|
97
177
|
return false;
|
|
98
178
|
}
|
|
99
|
-
|
|
100
|
-
|
|
179
|
+
delete policyStore.policies[grantId];
|
|
180
|
+
await writePolicyStore(stateDir, store);
|
|
181
|
+
return true;
|
|
101
182
|
});
|
|
102
183
|
}
|
package/src/guard.ts
CHANGED
|
@@ -5,9 +5,8 @@ import type {
|
|
|
5
5
|
PluginHookToolContext,
|
|
6
6
|
} from "openclaw/plugins/types";
|
|
7
7
|
import type { CedarlingConfig, CedarlingInstance } from "./cedarling.js";
|
|
8
|
-
import { getCedarling } from "./cedarling.js";
|
|
8
|
+
import { getCedarling, resetCedarlingInstance } from "./cedarling.js";
|
|
9
9
|
import { appendAuditEntry, createAuditEntryId } from "./audit.js";
|
|
10
|
-
import { listGrants, findActiveGrant } from "./grants.js";
|
|
11
10
|
import { getModeOverride } from "./mode.js";
|
|
12
11
|
import { buildDefaultPolicyStore } from "./policy.js";
|
|
13
12
|
import { ensurePolicyStore } from "./policy-store.js";
|
|
@@ -111,8 +110,6 @@ async function evaluateCedar(params: {
|
|
|
111
110
|
config: ClawClampConfig;
|
|
112
111
|
toolName: string;
|
|
113
112
|
risk: RiskLevel;
|
|
114
|
-
grantActive: boolean;
|
|
115
|
-
grantExpiresAt?: string;
|
|
116
113
|
}): Promise<CedarEvaluation> {
|
|
117
114
|
const now = Date.now();
|
|
118
115
|
const request = {
|
|
@@ -134,8 +131,6 @@ async function evaluateCedar(params: {
|
|
|
134
131
|
now,
|
|
135
132
|
tool: params.toolName,
|
|
136
133
|
risk: params.risk,
|
|
137
|
-
grant_active: params.grantActive,
|
|
138
|
-
grant_expires_at: params.grantExpiresAt ? Date.parse(params.grantExpiresAt) : 0,
|
|
139
134
|
},
|
|
140
135
|
};
|
|
141
136
|
|
|
@@ -154,6 +149,7 @@ export class ClawClampService {
|
|
|
154
149
|
private readonly stateDir: string;
|
|
155
150
|
private readonly api: OpenClawPluginApi;
|
|
156
151
|
private cedarlingPromise: Promise<CedarlingInstance> | null = null;
|
|
152
|
+
private cedarlingPolicyStoreJson: string | null = null;
|
|
157
153
|
|
|
158
154
|
constructor(params: { api: OpenClawPluginApi; config: ClawClampConfig; stateDir: string }) {
|
|
159
155
|
this.api = params.api;
|
|
@@ -170,7 +166,6 @@ export class ClawClampService {
|
|
|
170
166
|
const paramsSummary = summarizeParams(event.params, this.config);
|
|
171
167
|
const auditId = createAuditEntryId();
|
|
172
168
|
const mode = await this.getEffectiveMode();
|
|
173
|
-
const grant = await this.resolveGrant(toolName);
|
|
174
169
|
|
|
175
170
|
let cedarDecision: CedarEvaluation;
|
|
176
171
|
if (this.config.enabled) {
|
|
@@ -181,8 +176,6 @@ export class ClawClampService {
|
|
|
181
176
|
config: this.config,
|
|
182
177
|
toolName,
|
|
183
178
|
risk,
|
|
184
|
-
grantActive: Boolean(grant),
|
|
185
|
-
grantExpiresAt: grant?.expiresAt,
|
|
186
179
|
});
|
|
187
180
|
} catch (error) {
|
|
188
181
|
const reason = error instanceof Error ? error.message : String(error);
|
|
@@ -229,8 +222,6 @@ export class ClawClampService {
|
|
|
229
222
|
decision: finalDecision,
|
|
230
223
|
reason,
|
|
231
224
|
params: paramsSummary,
|
|
232
|
-
grantId: grant?.id,
|
|
233
|
-
grantExpiresAt: grant?.expiresAt,
|
|
234
225
|
grayMode: mode === "gray",
|
|
235
226
|
};
|
|
236
227
|
|
|
@@ -270,19 +261,19 @@ export class ClawClampService {
|
|
|
270
261
|
return override ?? this.config.mode;
|
|
271
262
|
}
|
|
272
263
|
|
|
273
|
-
async resolveGrant(toolName: string) {
|
|
274
|
-
const grants = await listGrants(this.stateDir);
|
|
275
|
-
return findActiveGrant(grants, toolName);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
264
|
private async getCedarlingInstance(): Promise<CedarlingInstance> {
|
|
279
|
-
|
|
280
|
-
|
|
265
|
+
const policyStore = await ensurePolicyStore({ stateDir: this.stateDir, config: this.config });
|
|
266
|
+
if (
|
|
267
|
+
!this.cedarlingPromise ||
|
|
268
|
+
(!policyStore.readOnly && policyStore.json && policyStore.json !== this.cedarlingPolicyStoreJson)
|
|
269
|
+
) {
|
|
281
270
|
const cedarConfig = buildCedarlingConfig({
|
|
282
271
|
config: this.config,
|
|
283
272
|
policyStoreLocal: policyStore.readOnly ? undefined : policyStore.json,
|
|
284
273
|
});
|
|
274
|
+
resetCedarlingInstance();
|
|
285
275
|
this.cedarlingPromise = getCedarling(cedarConfig);
|
|
276
|
+
this.cedarlingPolicyStoreJson = policyStore.readOnly ? null : (policyStore.json ?? null);
|
|
286
277
|
}
|
|
287
278
|
try {
|
|
288
279
|
return await this.cedarlingPromise;
|
|
@@ -294,6 +285,8 @@ export class ClawClampService {
|
|
|
294
285
|
|
|
295
286
|
async resetCedarling(): Promise<void> {
|
|
296
287
|
this.cedarlingPromise = null;
|
|
288
|
+
this.cedarlingPolicyStoreJson = null;
|
|
289
|
+
resetCedarlingInstance();
|
|
297
290
|
}
|
|
298
291
|
}
|
|
299
292
|
|
package/src/http.ts
CHANGED
|
@@ -279,13 +279,20 @@ export function createClawClampHttpHandler(params: {
|
|
|
279
279
|
}
|
|
280
280
|
|
|
281
281
|
if (apiPath === "logs" && req.method === "GET") {
|
|
282
|
-
const
|
|
283
|
-
const
|
|
284
|
-
|
|
282
|
+
const pageParam = parsed.searchParams.get("page");
|
|
283
|
+
const pageSizeParam = parsed.searchParams.get("pageSize");
|
|
284
|
+
const page = Math.max(1, Number(pageParam) || 1);
|
|
285
|
+
const pageSize = Math.min(
|
|
286
|
+
Math.max(1, Number(pageSizeParam) || 50),
|
|
285
287
|
params.config.audit.maxEntries,
|
|
286
288
|
);
|
|
287
|
-
const
|
|
288
|
-
sendJson(res, 200, {
|
|
289
|
+
const result = await readAuditEntries(params.stateDir, page, pageSize);
|
|
290
|
+
sendJson(res, 200, {
|
|
291
|
+
entries: result.entries.reverse(),
|
|
292
|
+
total: result.total,
|
|
293
|
+
page: result.page,
|
|
294
|
+
pageSize,
|
|
295
|
+
});
|
|
289
296
|
return true;
|
|
290
297
|
}
|
|
291
298
|
|
package/src/policy.ts
CHANGED
|
@@ -33,18 +33,11 @@ action "Invoke" appliesTo {
|
|
|
33
33
|
now: Long,
|
|
34
34
|
tool: String,
|
|
35
35
|
risk: String,
|
|
36
|
-
grant_active: Bool,
|
|
37
|
-
grant_expires_at: Long,
|
|
38
36
|
}
|
|
39
37
|
};
|
|
40
38
|
`;
|
|
41
39
|
|
|
42
|
-
const DEFAULT_POLICIES: string[] = [
|
|
43
|
-
`permit(principal, action, resource)
|
|
44
|
-
when {
|
|
45
|
-
action == Action::"Invoke" && context.grant_active == true
|
|
46
|
-
};`,
|
|
47
|
-
];
|
|
40
|
+
const DEFAULT_POLICIES: string[] = [];
|
|
48
41
|
|
|
49
42
|
const POLICY_STORE_ID = "clawclamp";
|
|
50
43
|
|