@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/src/http.ts ADDED
@@ -0,0 +1,433 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { IncomingMessage, ServerResponse } from "node:http";
4
+ import { listGrants, createGrant, revokeGrant } from "./grants.js";
5
+ import { readAuditEntries } from "./audit.js";
6
+ import { getModeOverride, setModeOverride } from "./mode.js";
7
+ import { createPolicy, deletePolicy, listPolicies, updatePolicy } from "./policy-store.js";
8
+ import type { ClawClampConfig, ClawClampMode } from "./types.js";
9
+
10
+ const API_PREFIX = "/plugins/clawclamp/api";
11
+ const ASSET_PREFIX = "/plugins/clawclamp/assets";
12
+ const ROOT_PATH = "/plugins/clawclamp";
13
+
14
+ const MIME_BY_EXT: Record<string, string> = {
15
+ ".html": "text/html; charset=utf-8",
16
+ ".js": "application/javascript; charset=utf-8",
17
+ ".css": "text/css; charset=utf-8",
18
+ ".json": "application/json; charset=utf-8",
19
+ };
20
+
21
+ function setSharedHeaders(res: ServerResponse, contentType: string): void {
22
+ res.setHeader("cache-control", "no-store, max-age=0");
23
+ res.setHeader("content-type", contentType);
24
+ res.setHeader("x-content-type-options", "nosniff");
25
+ res.setHeader("referrer-policy", "no-referrer");
26
+ }
27
+
28
+ function sendJson(res: ServerResponse, status: number, payload: unknown): void {
29
+ res.statusCode = status;
30
+ setSharedHeaders(res, "application/json; charset=utf-8");
31
+ res.end(JSON.stringify(payload));
32
+ }
33
+
34
+ function sendText(res: ServerResponse, status: number, text: string): void {
35
+ res.statusCode = status;
36
+ setSharedHeaders(res, "text/plain; charset=utf-8");
37
+ res.end(text);
38
+ }
39
+
40
+ function parseUrl(rawUrl?: string): URL | null {
41
+ if (!rawUrl) {
42
+ return null;
43
+ }
44
+ try {
45
+ return new URL(rawUrl, "http://127.0.0.1");
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ function getHeader(req: IncomingMessage, name: string): string | undefined {
52
+ const raw = req.headers[name.toLowerCase()];
53
+ if (typeof raw === "string") {
54
+ return raw;
55
+ }
56
+ if (Array.isArray(raw)) {
57
+ return raw[0];
58
+ }
59
+ return undefined;
60
+ }
61
+
62
+ function getBearerToken(req: IncomingMessage): string | undefined {
63
+ const raw = getHeader(req, "authorization")?.trim() ?? "";
64
+ if (!raw.toLowerCase().startsWith("bearer ")) {
65
+ return undefined;
66
+ }
67
+ const token = raw.slice(7).trim();
68
+ return token || undefined;
69
+ }
70
+
71
+ function hasProxyForwardingHints(req: IncomingMessage): boolean {
72
+ const headers = req.headers ?? {};
73
+ return Boolean(
74
+ headers["x-forwarded-for"] ||
75
+ headers["x-real-ip"] ||
76
+ headers.forwarded ||
77
+ headers["x-forwarded-host"] ||
78
+ headers["x-forwarded-proto"],
79
+ );
80
+ }
81
+
82
+ function normalizeRemoteClientKey(remoteAddress: string | undefined): string {
83
+ const normalized = remoteAddress?.trim().toLowerCase();
84
+ if (!normalized) {
85
+ return "unknown";
86
+ }
87
+ return normalized.startsWith("::ffff:") ? normalized.slice("::ffff:".length) : normalized;
88
+ }
89
+
90
+ function isLoopbackClientIp(clientIp: string): boolean {
91
+ return clientIp === "127.0.0.1" || clientIp === "::1";
92
+ }
93
+
94
+ function isLoopbackRequest(req: IncomingMessage): boolean {
95
+ const remoteKey = normalizeRemoteClientKey(req.socket?.remoteAddress);
96
+ return isLoopbackClientIp(remoteKey) && !hasProxyForwardingHints(req);
97
+ }
98
+
99
+ function resolveAuthToken(params: {
100
+ req: IncomingMessage;
101
+ parsed: URL;
102
+ }): string | undefined {
103
+ const queryToken = params.parsed.searchParams.get("token")?.trim();
104
+ if (queryToken) {
105
+ return queryToken;
106
+ }
107
+ const headerToken = getHeader(params.req, "x-openclaw-token")?.trim();
108
+ if (headerToken) {
109
+ return headerToken;
110
+ }
111
+ return getBearerToken(params.req);
112
+ }
113
+
114
+ function isAuthorizedRequest(params: {
115
+ req: IncomingMessage;
116
+ parsed: URL;
117
+ config: ClawClampConfig;
118
+ gatewayToken?: string;
119
+ }): boolean {
120
+ if (isLoopbackRequest(params.req)) {
121
+ return true;
122
+ }
123
+ const token = resolveAuthToken({ req: params.req, parsed: params.parsed });
124
+ if (!token) {
125
+ return false;
126
+ }
127
+ if (params.config.uiToken && token === params.config.uiToken) {
128
+ return true;
129
+ }
130
+ if (params.gatewayToken && token === params.gatewayToken) {
131
+ return true;
132
+ }
133
+ return false;
134
+ }
135
+
136
+ async function readJsonBody(req: IncomingMessage, limit = 64_000): Promise<unknown> {
137
+ const chunks: Buffer[] = [];
138
+ let size = 0;
139
+ for await (const chunk of req) {
140
+ size += chunk.length;
141
+ if (size > limit) {
142
+ throw new Error("Request body too large");
143
+ }
144
+ chunks.push(chunk);
145
+ }
146
+ if (chunks.length === 0) {
147
+ return undefined;
148
+ }
149
+ const raw = Buffer.concat(chunks).toString("utf8");
150
+ if (!raw.trim()) {
151
+ return undefined;
152
+ }
153
+ return JSON.parse(raw);
154
+ }
155
+
156
+ async function serveAsset(
157
+ req: IncomingMessage,
158
+ res: ServerResponse,
159
+ assetsDir: string,
160
+ pathname: string,
161
+ ): Promise<boolean> {
162
+ if (!pathname.startsWith(ASSET_PREFIX)) {
163
+ return false;
164
+ }
165
+ if (req.method !== "GET" && req.method !== "HEAD") {
166
+ sendText(res, 405, "Method not allowed");
167
+ return true;
168
+ }
169
+ const relative = pathname.slice(ASSET_PREFIX.length).replace(/^\//, "");
170
+ const resolved = path.resolve(assetsDir, relative);
171
+ if (!resolved.startsWith(assetsDir)) {
172
+ sendText(res, 404, "Not found");
173
+ return true;
174
+ }
175
+ try {
176
+ const stat = await fs.stat(resolved);
177
+ if (!stat.isFile()) {
178
+ sendText(res, 404, "Not found");
179
+ return true;
180
+ }
181
+ const ext = path.extname(resolved).toLowerCase();
182
+ const contentType = MIME_BY_EXT[ext] ?? "application/octet-stream";
183
+ const body = await fs.readFile(resolved);
184
+ res.statusCode = 200;
185
+ setSharedHeaders(res, contentType);
186
+ res.end(req.method === "HEAD" ? undefined : body);
187
+ return true;
188
+ } catch {
189
+ sendText(res, 404, "Not found");
190
+ return true;
191
+ }
192
+ }
193
+
194
+ export function createClawClampHttpHandler(params: {
195
+ stateDir: string;
196
+ config: ClawClampConfig;
197
+ assetsDir: string;
198
+ gatewayToken?: string;
199
+ onPolicyUpdate?: () => Promise<void>;
200
+ }) {
201
+ const assetsDir = path.resolve(params.assetsDir);
202
+
203
+ return async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {
204
+ const parsed = parseUrl(req.url);
205
+ if (!parsed) {
206
+ return false;
207
+ }
208
+
209
+ if (!isAuthorizedRequest({ req, parsed, config: params.config, gatewayToken: params.gatewayToken })) {
210
+ if (parsed.pathname.startsWith(API_PREFIX)) {
211
+ sendJson(res, 401, { error: "unauthorized" });
212
+ } else {
213
+ sendText(res, 401, "Unauthorized");
214
+ }
215
+ return true;
216
+ }
217
+
218
+ if (await serveAsset(req, res, assetsDir, parsed.pathname)) {
219
+ return true;
220
+ }
221
+
222
+ if (parsed.pathname === ROOT_PATH || parsed.pathname === `${ROOT_PATH}/`) {
223
+ if (req.method !== "GET" && req.method !== "HEAD") {
224
+ sendText(res, 405, "Method not allowed");
225
+ return true;
226
+ }
227
+ const indexPath = path.join(assetsDir, "index.html");
228
+ try {
229
+ const body = await fs.readFile(indexPath, "utf8");
230
+ res.statusCode = 200;
231
+ setSharedHeaders(res, "text/html; charset=utf-8");
232
+ res.end(req.method === "HEAD" ? undefined : body);
233
+ return true;
234
+ } catch {
235
+ sendText(res, 500, "Failed to load UI");
236
+ return true;
237
+ }
238
+ }
239
+
240
+ if (!parsed.pathname.startsWith(API_PREFIX)) {
241
+ return false;
242
+ }
243
+
244
+ const apiPath = parsed.pathname.slice(API_PREFIX.length).replace(/^\//, "");
245
+
246
+ if (apiPath === "state" && req.method === "GET") {
247
+ const modeOverride = await getModeOverride(params.stateDir);
248
+ sendJson(res, 200, {
249
+ enabled: params.config.enabled,
250
+ mode: modeOverride ?? params.config.mode,
251
+ modeOverride: modeOverride ?? null,
252
+ configMode: params.config.mode,
253
+ grants: {
254
+ defaultTtlSeconds: params.config.grants.defaultTtlSeconds,
255
+ maxTtlSeconds: params.config.grants.maxTtlSeconds,
256
+ },
257
+ audit: {
258
+ maxEntries: params.config.audit.maxEntries,
259
+ },
260
+ });
261
+ return true;
262
+ }
263
+
264
+ if (apiPath === "mode" && req.method === "POST") {
265
+ try {
266
+ const body = (await readJsonBody(req)) as Record<string, unknown>;
267
+ const mode = body?.mode;
268
+ if (mode !== "enforce" && mode !== "gray") {
269
+ sendJson(res, 400, { error: "mode must be enforce or gray" });
270
+ return true;
271
+ }
272
+ await setModeOverride(params.stateDir, mode as ClawClampMode);
273
+ sendJson(res, 200, { ok: true, mode });
274
+ return true;
275
+ } catch (error) {
276
+ sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
277
+ return true;
278
+ }
279
+ }
280
+
281
+ if (apiPath === "logs" && req.method === "GET") {
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),
287
+ params.config.audit.maxEntries,
288
+ );
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
+ });
296
+ return true;
297
+ }
298
+
299
+ if (apiPath === "policies" && req.method === "GET") {
300
+ if (params.config.policyStoreUri) {
301
+ sendJson(res, 200, { readOnly: true, policies: [] });
302
+ return true;
303
+ }
304
+ const { policies } = await listPolicies({ stateDir: params.stateDir });
305
+ sendJson(res, 200, { readOnly: false, policies });
306
+ return true;
307
+ }
308
+
309
+ if (apiPath === "policies" && req.method === "POST") {
310
+ if (params.config.policyStoreUri) {
311
+ sendJson(res, 400, { error: "policyStoreUri is read-only" });
312
+ return true;
313
+ }
314
+ try {
315
+ const body = (await readJsonBody(req)) as Record<string, unknown>;
316
+ const content = typeof body?.content === "string" ? body.content : "";
317
+ const id = typeof body?.id === "string" ? body.id : undefined;
318
+ if (!content.trim()) {
319
+ sendJson(res, 400, { error: "content is required" });
320
+ return true;
321
+ }
322
+ const policy = await createPolicy({ stateDir: params.stateDir, id, content });
323
+ if (params.onPolicyUpdate) {
324
+ await params.onPolicyUpdate();
325
+ }
326
+ sendJson(res, 200, { policy });
327
+ return true;
328
+ } catch (error) {
329
+ sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
330
+ return true;
331
+ }
332
+ }
333
+
334
+ if (apiPath.startsWith("policies/") && req.method === "PUT") {
335
+ if (params.config.policyStoreUri) {
336
+ sendJson(res, 400, { error: "policyStoreUri is read-only" });
337
+ return true;
338
+ }
339
+ try {
340
+ const id = apiPath.slice("policies/".length);
341
+ if (!id) {
342
+ sendJson(res, 400, { error: "policy id required" });
343
+ return true;
344
+ }
345
+ const body = (await readJsonBody(req)) as Record<string, unknown>;
346
+ const content = typeof body?.content === "string" ? body.content : "";
347
+ if (!content.trim()) {
348
+ sendJson(res, 400, { error: "content is required" });
349
+ return true;
350
+ }
351
+ const policy = await updatePolicy({ stateDir: params.stateDir, id, content });
352
+ if (params.onPolicyUpdate) {
353
+ await params.onPolicyUpdate();
354
+ }
355
+ sendJson(res, 200, { policy });
356
+ return true;
357
+ } catch (error) {
358
+ sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
359
+ return true;
360
+ }
361
+ }
362
+
363
+ if (apiPath.startsWith("policies/") && req.method === "DELETE") {
364
+ if (params.config.policyStoreUri) {
365
+ sendJson(res, 400, { error: "policyStoreUri is read-only" });
366
+ return true;
367
+ }
368
+ const id = apiPath.slice("policies/".length);
369
+ if (!id) {
370
+ sendJson(res, 400, { error: "policy id required" });
371
+ return true;
372
+ }
373
+ const ok = await deletePolicy({ stateDir: params.stateDir, id });
374
+ if (params.onPolicyUpdate) {
375
+ await params.onPolicyUpdate();
376
+ }
377
+ sendJson(res, 200, { ok });
378
+ return true;
379
+ }
380
+
381
+ if (apiPath === "grants" && req.method === "GET") {
382
+ const grants = await listGrants(params.stateDir);
383
+ sendJson(res, 200, { grants });
384
+ return true;
385
+ }
386
+
387
+ if (apiPath === "grants" && req.method === "POST") {
388
+ try {
389
+ const body = (await readJsonBody(req)) as Record<string, unknown>;
390
+ const toolName = typeof body?.toolName === "string" ? body.toolName.trim() : "";
391
+ if (!toolName) {
392
+ sendJson(res, 400, { error: "toolName is required" });
393
+ return true;
394
+ }
395
+ const ttlSeconds =
396
+ typeof body?.ttlSeconds === "number" ? body.ttlSeconds : undefined;
397
+ const note = typeof body?.note === "string" ? body.note : undefined;
398
+ const grant = await createGrant({
399
+ stateDir: params.stateDir,
400
+ config: params.config,
401
+ toolName,
402
+ ttlSeconds,
403
+ note,
404
+ });
405
+ if (params.onPolicyUpdate) {
406
+ await params.onPolicyUpdate();
407
+ }
408
+ sendJson(res, 200, { grant });
409
+ return true;
410
+ } catch (error) {
411
+ sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
412
+ return true;
413
+ }
414
+ }
415
+
416
+ if (apiPath.startsWith("grants/") && req.method === "DELETE") {
417
+ const grantId = apiPath.slice("grants/".length);
418
+ if (!grantId) {
419
+ sendJson(res, 400, { error: "grant id required" });
420
+ return true;
421
+ }
422
+ const removed = await revokeGrant(params.stateDir, grantId);
423
+ if (params.onPolicyUpdate) {
424
+ await params.onPolicyUpdate();
425
+ }
426
+ sendJson(res, 200, { ok: removed });
427
+ return true;
428
+ }
429
+
430
+ sendJson(res, 404, { error: "Not found" });
431
+ return true;
432
+ };
433
+ }
package/src/mode.ts ADDED
@@ -0,0 +1,48 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk";
4
+ import type { ClawClampMode, ModeState } from "./types.js";
5
+ import { withStateFileLock } from "./storage.js";
6
+
7
+ const MODE_FILE = "mode.json";
8
+
9
+ function resolveModePath(stateDir: string): string {
10
+ return path.join(stateDir, "clawclamp", MODE_FILE);
11
+ }
12
+
13
+ async function readModeState(stateDir: string): Promise<ModeState> {
14
+ const filePath = resolveModePath(stateDir);
15
+ const { value } = await readJsonFileWithFallback(filePath, {} as ModeState);
16
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
17
+ return {};
18
+ }
19
+ return value as ModeState;
20
+ }
21
+
22
+ async function writeModeState(stateDir: string, state: ModeState): Promise<void> {
23
+ const filePath = resolveModePath(stateDir);
24
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
25
+ await writeJsonFileAtomically(filePath, state);
26
+ }
27
+
28
+ export async function getModeOverride(stateDir: string): Promise<ClawClampMode | undefined> {
29
+ return withStateFileLock(stateDir, "mode", async () => {
30
+ const state = await readModeState(stateDir);
31
+ return state.modeOverride;
32
+ });
33
+ }
34
+
35
+ export async function setModeOverride(
36
+ stateDir: string,
37
+ mode: ClawClampMode | undefined,
38
+ ): Promise<void> {
39
+ return withStateFileLock(stateDir, "mode", async () => {
40
+ const state = await readModeState(stateDir);
41
+ const next: ModeState = {
42
+ ...state,
43
+ modeOverride: mode,
44
+ updatedAt: new Date().toISOString(),
45
+ };
46
+ await writeModeState(stateDir, next);
47
+ });
48
+ }
@@ -0,0 +1,186 @@
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
+ type PolicyRecord = {
14
+ cedar_version?: string;
15
+ name?: string;
16
+ description?: string;
17
+ policy_content: string;
18
+ };
19
+
20
+ type PolicyStoreBody = {
21
+ name?: string;
22
+ description?: string;
23
+ schema?: EncodedContent;
24
+ trusted_issuers?: Record<string, unknown>;
25
+ policies?: Record<string, PolicyRecord>;
26
+ };
27
+
28
+ type EncodedContent = {
29
+ encoding?: "none" | "base64";
30
+ content_type?: "cedar" | "cedar-json";
31
+ body?: string;
32
+ };
33
+
34
+ export type PolicyStoreSnapshot = {
35
+ cedar_version: string;
36
+ policy_stores: Record<string, PolicyStoreBody>;
37
+ };
38
+
39
+ const POLICY_STORE_ID = "clawclamp";
40
+
41
+ function resolvePolicyPath(stateDir: string): string {
42
+ return path.join(stateDir, "clawclamp", POLICY_FILE);
43
+ }
44
+
45
+ function decodeBase64(value: string): string {
46
+ return Buffer.from(value, "base64").toString("utf8");
47
+ }
48
+
49
+ function encodeBase64(value: string): string {
50
+ return Buffer.from(value, "utf8").toString("base64");
51
+ }
52
+
53
+ async function readPolicyStore(stateDir: string): Promise<PolicyStoreSnapshot> {
54
+ const filePath = resolvePolicyPath(stateDir);
55
+ const { value } = await readJsonFileWithFallback<PolicyStoreSnapshot>(
56
+ filePath,
57
+ buildDefaultPolicyStore() as PolicyStoreSnapshot,
58
+ );
59
+ return value;
60
+ }
61
+
62
+ async function writePolicyStore(stateDir: string, store: PolicyStoreSnapshot): Promise<void> {
63
+ const filePath = resolvePolicyPath(stateDir);
64
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
65
+ await writeJsonFileAtomically(filePath, store);
66
+ }
67
+
68
+ function getWritableStore(store: PolicyStoreSnapshot): PolicyStoreBody {
69
+ const existing = store.policy_stores?.[POLICY_STORE_ID];
70
+ if (existing) {
71
+ return existing;
72
+ }
73
+ const fallback = buildDefaultPolicyStore() as PolicyStoreSnapshot;
74
+ const created = fallback.policy_stores[POLICY_STORE_ID] ?? {
75
+ policies: {},
76
+ schema: undefined,
77
+ trusted_issuers: {},
78
+ };
79
+ if (!store.policy_stores) {
80
+ store.policy_stores = {};
81
+ }
82
+ store.policy_stores[POLICY_STORE_ID] = created;
83
+ return created;
84
+ }
85
+
86
+ export async function ensurePolicyStore(params: {
87
+ stateDir: string;
88
+ config: ClawClampConfig;
89
+ }): Promise<{ json: string; readOnly: boolean } | { json?: undefined; readOnly: true }> {
90
+ if (params.config.policyStoreUri) {
91
+ return { readOnly: true };
92
+ }
93
+ return withStateFileLock(params.stateDir, "policy-store", async () => {
94
+ const filePath = resolvePolicyPath(params.stateDir);
95
+ try {
96
+ const raw = await fs.readFile(filePath, "utf8");
97
+ return { json: raw, readOnly: false };
98
+ } catch (error) {
99
+ const code = (error as { code?: string }).code;
100
+ if (code !== "ENOENT") {
101
+ throw error;
102
+ }
103
+ }
104
+
105
+ const initial = params.config.policyStoreLocal
106
+ ? (JSON.parse(params.config.policyStoreLocal) as PolicyStoreSnapshot)
107
+ : (buildDefaultPolicyStore() as PolicyStoreSnapshot);
108
+ await writePolicyStore(params.stateDir, initial);
109
+ return { json: JSON.stringify(initial), readOnly: false };
110
+ });
111
+ }
112
+
113
+ export async function listPolicies(params: {
114
+ stateDir: string;
115
+ }): Promise<{ policies: PolicyEntry[] }> {
116
+ return withStateFileLock(params.stateDir, "policy-store", async () => {
117
+ const store = await readPolicyStore(params.stateDir);
118
+ const policyStore = getWritableStore(store);
119
+ const policies: PolicyEntry[] = Object.entries(policyStore.policies ?? {}).map(([id, payload]) => ({
120
+ id,
121
+ content: decodeBase64(payload.policy_content ?? ""),
122
+ }));
123
+ return { policies };
124
+ });
125
+ }
126
+
127
+ export async function createPolicy(params: {
128
+ stateDir: string;
129
+ id?: string;
130
+ content: string;
131
+ }): Promise<PolicyEntry> {
132
+ return withStateFileLock(params.stateDir, "policy-store", async () => {
133
+ const store = await readPolicyStore(params.stateDir);
134
+ const policyStore = getWritableStore(store);
135
+ const id = params.id?.trim() || `clawclamp-${randomUUID()}`;
136
+ if (!policyStore.policies) {
137
+ policyStore.policies = {};
138
+ }
139
+ if (policyStore.policies[id]) {
140
+ throw new Error("policy id already exists");
141
+ }
142
+ policyStore.policies[id] = {
143
+ cedar_version: store.cedar_version,
144
+ name: id,
145
+ description: "Created from Clawclamp UI.",
146
+ policy_content: encodeBase64(params.content),
147
+ };
148
+ await writePolicyStore(params.stateDir, store);
149
+ return { id, content: params.content };
150
+ });
151
+ }
152
+
153
+ export async function updatePolicy(params: {
154
+ stateDir: string;
155
+ id: string;
156
+ content: string;
157
+ }): Promise<PolicyEntry> {
158
+ return withStateFileLock(params.stateDir, "policy-store", async () => {
159
+ const store = await readPolicyStore(params.stateDir);
160
+ const policyStore = getWritableStore(store);
161
+ if (!policyStore.policies?.[params.id]) {
162
+ throw new Error("policy id not found");
163
+ }
164
+ policyStore.policies[params.id] = {
165
+ cedar_version: store.cedar_version,
166
+ name: params.id,
167
+ description: "Updated from Clawclamp UI.",
168
+ policy_content: encodeBase64(params.content),
169
+ };
170
+ await writePolicyStore(params.stateDir, store);
171
+ return { id: params.id, content: params.content };
172
+ });
173
+ }
174
+
175
+ export async function deletePolicy(params: { stateDir: string; id: string }): Promise<boolean> {
176
+ return withStateFileLock(params.stateDir, "policy-store", async () => {
177
+ const store = await readPolicyStore(params.stateDir);
178
+ const policyStore = getWritableStore(store);
179
+ if (!policyStore.policies?.[params.id]) {
180
+ return false;
181
+ }
182
+ delete policyStore.policies[params.id];
183
+ await writePolicyStore(params.stateDir, store);
184
+ return true;
185
+ });
186
+ }