@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/src/audit.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import type { AuditEntry } from "./types.js";
|
|
5
|
+
import { withStateFileLock } from "./storage.js";
|
|
6
|
+
|
|
7
|
+
const AUDIT_FILE = "audit.jsonl";
|
|
8
|
+
const AUDIT_ROTATE_MAX_LINES = 5000;
|
|
9
|
+
const AUDIT_ROTATE_KEEP_LINES = 2500;
|
|
10
|
+
|
|
11
|
+
function resolveAuditPath(stateDir: string): string {
|
|
12
|
+
return path.join(stateDir, "clawclamp", AUDIT_FILE);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function resolveAuditArchivePath(stateDir: string, timestamp: string): string {
|
|
16
|
+
return path.join(stateDir, "clawclamp", `audit-${timestamp}.jsonl`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createAuditEntryId(): string {
|
|
20
|
+
return randomUUID();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function appendAuditEntry(stateDir: string, entry: AuditEntry): Promise<void> {
|
|
24
|
+
await withStateFileLock(stateDir, "audit", async () => {
|
|
25
|
+
const filePath = resolveAuditPath(stateDir);
|
|
26
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
27
|
+
await fs.appendFile(filePath, `${JSON.stringify(entry)}\n`, "utf8");
|
|
28
|
+
await rotateAuditLogIfNeeded(stateDir, filePath);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
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 }> {
|
|
60
|
+
return withStateFileLock(stateDir, "audit", async () => {
|
|
61
|
+
const filePath = resolveAuditPath(stateDir);
|
|
62
|
+
let raw: string;
|
|
63
|
+
try {
|
|
64
|
+
raw = await fs.readFile(filePath, "utf8");
|
|
65
|
+
} catch (error) {
|
|
66
|
+
const code = (error as { code?: string }).code;
|
|
67
|
+
if (code === "ENOENT") {
|
|
68
|
+
return { entries: [], total: 0, page: 1 };
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const lines = raw.trim().split("\n").filter(Boolean);
|
|
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);
|
|
81
|
+
const entries: AuditEntry[] = [];
|
|
82
|
+
for (const line of recent) {
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(line) as AuditEntry;
|
|
85
|
+
if (parsed && typeof parsed === "object") {
|
|
86
|
+
entries.push(parsed);
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// Ignore malformed lines.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { entries, total, page: safePage };
|
|
93
|
+
});
|
|
94
|
+
}
|
package/src/cedarling.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import initWasm, { init } from "@janssenproject/cedarling_wasm";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
|
|
5
|
+
export type CedarlingConfig = Record<string, string | number | boolean>;
|
|
6
|
+
|
|
7
|
+
export type CedarlingAuthorizeResult = {
|
|
8
|
+
json_string: () => string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type CedarlingInstance = {
|
|
12
|
+
authorize_unsigned: (request: unknown) => Promise<CedarlingAuthorizeResult>;
|
|
13
|
+
pop_logs: () => unknown[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
let cedarlingPromise: Promise<CedarlingInstance> | null = null;
|
|
18
|
+
let wasmInitPromise: Promise<void> | null = null;
|
|
19
|
+
|
|
20
|
+
export async function getCedarling(config: CedarlingConfig): Promise<CedarlingInstance> {
|
|
21
|
+
if (!cedarlingPromise) {
|
|
22
|
+
cedarlingPromise = createCedarling(config);
|
|
23
|
+
}
|
|
24
|
+
return cedarlingPromise;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function resetCedarlingInstance(): void {
|
|
28
|
+
cedarlingPromise = null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function ensureWasmInitialized(): Promise<void> {
|
|
32
|
+
if (!wasmInitPromise) {
|
|
33
|
+
wasmInitPromise = (async () => {
|
|
34
|
+
const wasmPath = require.resolve("@janssenproject/cedarling_wasm/cedarling_wasm_bg.wasm");
|
|
35
|
+
const wasmBytes = await readFile(wasmPath);
|
|
36
|
+
await initWasm(wasmBytes);
|
|
37
|
+
})();
|
|
38
|
+
}
|
|
39
|
+
return wasmInitPromise;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function createCedarling(config: CedarlingConfig): Promise<CedarlingInstance> {
|
|
43
|
+
await ensureWasmInitialized();
|
|
44
|
+
const instance = await init(config);
|
|
45
|
+
return instance as CedarlingInstance;
|
|
46
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { ClawClampConfig, ClawClampMode, RiskLevel } from "./types.js";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_RISK_OVERRIDES: Record<string, RiskLevel> = {
|
|
5
|
+
read: "low",
|
|
6
|
+
memory_get: "low",
|
|
7
|
+
memory_search: "low",
|
|
8
|
+
sessions_list: "low",
|
|
9
|
+
sessions_history: "low",
|
|
10
|
+
session_status: "low",
|
|
11
|
+
agents_list: "low",
|
|
12
|
+
web_search: "medium",
|
|
13
|
+
web_fetch: "medium",
|
|
14
|
+
image: "medium",
|
|
15
|
+
tts: "medium",
|
|
16
|
+
canvas: "medium",
|
|
17
|
+
browser: "high",
|
|
18
|
+
write: "high",
|
|
19
|
+
edit: "high",
|
|
20
|
+
apply_patch: "high",
|
|
21
|
+
exec: "high",
|
|
22
|
+
process: "high",
|
|
23
|
+
message: "high",
|
|
24
|
+
cron: "high",
|
|
25
|
+
gateway: "high",
|
|
26
|
+
nodes: "high",
|
|
27
|
+
sessions_send: "high",
|
|
28
|
+
sessions_spawn: "high",
|
|
29
|
+
subagents: "high",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const DEFAULT_CLAWCLAMP_CONFIG: ClawClampConfig = {
|
|
33
|
+
enabled: true,
|
|
34
|
+
mode: "gray",
|
|
35
|
+
principalId: "openclaw",
|
|
36
|
+
policyFailOpen: false,
|
|
37
|
+
risk: {
|
|
38
|
+
default: "high",
|
|
39
|
+
overrides: DEFAULT_RISK_OVERRIDES,
|
|
40
|
+
},
|
|
41
|
+
grants: {
|
|
42
|
+
defaultTtlSeconds: 900,
|
|
43
|
+
maxTtlSeconds: 3600,
|
|
44
|
+
},
|
|
45
|
+
audit: {
|
|
46
|
+
maxEntries: 500,
|
|
47
|
+
includeParams: true,
|
|
48
|
+
maxParamLength: 2048,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const RISK_LEVELS = ["low", "medium", "high"] as const;
|
|
53
|
+
|
|
54
|
+
const CEDAR_GUARD_CONFIG_JSON_SCHEMA = {
|
|
55
|
+
type: "object",
|
|
56
|
+
additionalProperties: false,
|
|
57
|
+
properties: {
|
|
58
|
+
enabled: { type: "boolean", default: DEFAULT_CLAWCLAMP_CONFIG.enabled },
|
|
59
|
+
mode: {
|
|
60
|
+
type: "string",
|
|
61
|
+
enum: ["enforce", "gray"],
|
|
62
|
+
default: DEFAULT_CLAWCLAMP_CONFIG.mode,
|
|
63
|
+
},
|
|
64
|
+
principalId: { type: "string", default: DEFAULT_CLAWCLAMP_CONFIG.principalId },
|
|
65
|
+
policyStoreUri: { type: "string" },
|
|
66
|
+
policyStoreLocal: { type: "string" },
|
|
67
|
+
uiToken: { type: "string" },
|
|
68
|
+
policyFailOpen: { type: "boolean", default: DEFAULT_CLAWCLAMP_CONFIG.policyFailOpen },
|
|
69
|
+
risk: {
|
|
70
|
+
type: "object",
|
|
71
|
+
additionalProperties: false,
|
|
72
|
+
properties: {
|
|
73
|
+
default: {
|
|
74
|
+
type: "string",
|
|
75
|
+
enum: [...RISK_LEVELS],
|
|
76
|
+
default: DEFAULT_CLAWCLAMP_CONFIG.risk.default,
|
|
77
|
+
},
|
|
78
|
+
overrides: {
|
|
79
|
+
type: "object",
|
|
80
|
+
additionalProperties: {
|
|
81
|
+
type: "string",
|
|
82
|
+
enum: [...RISK_LEVELS],
|
|
83
|
+
},
|
|
84
|
+
default: DEFAULT_CLAWCLAMP_CONFIG.risk.overrides,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
grants: {
|
|
89
|
+
type: "object",
|
|
90
|
+
additionalProperties: false,
|
|
91
|
+
properties: {
|
|
92
|
+
defaultTtlSeconds: {
|
|
93
|
+
type: "number",
|
|
94
|
+
minimum: 60,
|
|
95
|
+
maximum: 86_400,
|
|
96
|
+
default: DEFAULT_CLAWCLAMP_CONFIG.grants.defaultTtlSeconds,
|
|
97
|
+
},
|
|
98
|
+
maxTtlSeconds: {
|
|
99
|
+
type: "number",
|
|
100
|
+
minimum: 60,
|
|
101
|
+
maximum: 86_400,
|
|
102
|
+
default: DEFAULT_CLAWCLAMP_CONFIG.grants.maxTtlSeconds,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
audit: {
|
|
107
|
+
type: "object",
|
|
108
|
+
additionalProperties: false,
|
|
109
|
+
properties: {
|
|
110
|
+
maxEntries: {
|
|
111
|
+
type: "number",
|
|
112
|
+
minimum: 50,
|
|
113
|
+
maximum: 10_000,
|
|
114
|
+
default: DEFAULT_CLAWCLAMP_CONFIG.audit.maxEntries,
|
|
115
|
+
},
|
|
116
|
+
includeParams: {
|
|
117
|
+
type: "boolean",
|
|
118
|
+
default: DEFAULT_CLAWCLAMP_CONFIG.audit.includeParams,
|
|
119
|
+
},
|
|
120
|
+
maxParamLength: {
|
|
121
|
+
type: "number",
|
|
122
|
+
minimum: 256,
|
|
123
|
+
maximum: 32_000,
|
|
124
|
+
default: DEFAULT_CLAWCLAMP_CONFIG.audit.maxParamLength,
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
} as const;
|
|
130
|
+
|
|
131
|
+
export const clawClampConfigSchema: OpenClawPluginConfigSchema = {
|
|
132
|
+
safeParse(value: unknown) {
|
|
133
|
+
if (value === undefined) {
|
|
134
|
+
return { success: true, data: undefined };
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
return { success: true, data: resolveClawClampConfig(value) };
|
|
138
|
+
} catch (error) {
|
|
139
|
+
return {
|
|
140
|
+
success: false,
|
|
141
|
+
error: {
|
|
142
|
+
issues: [{ path: [], message: error instanceof Error ? error.message : String(error) }],
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
jsonSchema: CEDAR_GUARD_CONFIG_JSON_SCHEMA,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
function isRiskLevel(value: unknown): value is RiskLevel {
|
|
151
|
+
return typeof value === "string" && (RISK_LEVELS as readonly string[]).includes(value);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isMode(value: unknown): value is ClawClampMode {
|
|
155
|
+
return value === "enforce" || value === "gray";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function resolveClawClampConfig(input: unknown): ClawClampConfig {
|
|
159
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
160
|
+
return { ...DEFAULT_CLAWCLAMP_CONFIG };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const raw = input as Record<string, unknown>;
|
|
164
|
+
const riskOverrides = { ...DEFAULT_CLAWCLAMP_CONFIG.risk.overrides };
|
|
165
|
+
|
|
166
|
+
if (raw.risk && typeof raw.risk === "object" && !Array.isArray(raw.risk)) {
|
|
167
|
+
const risk = raw.risk as Record<string, unknown>;
|
|
168
|
+
if (risk.overrides && typeof risk.overrides === "object" && !Array.isArray(risk.overrides)) {
|
|
169
|
+
for (const [tool, level] of Object.entries(risk.overrides)) {
|
|
170
|
+
if (isRiskLevel(level)) {
|
|
171
|
+
riskOverrides[tool] = level;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const resolved: ClawClampConfig = {
|
|
178
|
+
enabled: typeof raw.enabled === "boolean" ? raw.enabled : DEFAULT_CLAWCLAMP_CONFIG.enabled,
|
|
179
|
+
mode: isMode(raw.mode) ? raw.mode : DEFAULT_CLAWCLAMP_CONFIG.mode,
|
|
180
|
+
principalId:
|
|
181
|
+
typeof raw.principalId === "string" && raw.principalId.trim()
|
|
182
|
+
? raw.principalId.trim()
|
|
183
|
+
: DEFAULT_CLAWCLAMP_CONFIG.principalId,
|
|
184
|
+
policyStoreUri:
|
|
185
|
+
typeof raw.policyStoreUri === "string" && raw.policyStoreUri.trim()
|
|
186
|
+
? raw.policyStoreUri.trim()
|
|
187
|
+
: undefined,
|
|
188
|
+
policyStoreLocal:
|
|
189
|
+
typeof raw.policyStoreLocal === "string" && raw.policyStoreLocal.trim()
|
|
190
|
+
? raw.policyStoreLocal.trim()
|
|
191
|
+
: undefined,
|
|
192
|
+
uiToken:
|
|
193
|
+
typeof raw.uiToken === "string" && raw.uiToken.trim()
|
|
194
|
+
? raw.uiToken.trim()
|
|
195
|
+
: undefined,
|
|
196
|
+
policyFailOpen:
|
|
197
|
+
typeof raw.policyFailOpen === "boolean"
|
|
198
|
+
? raw.policyFailOpen
|
|
199
|
+
: DEFAULT_CLAWCLAMP_CONFIG.policyFailOpen,
|
|
200
|
+
risk: {
|
|
201
|
+
default:
|
|
202
|
+
raw.risk && typeof raw.risk === "object" && !Array.isArray(raw.risk)
|
|
203
|
+
? isRiskLevel((raw.risk as Record<string, unknown>).default)
|
|
204
|
+
? ((raw.risk as Record<string, unknown>).default as RiskLevel)
|
|
205
|
+
: DEFAULT_CLAWCLAMP_CONFIG.risk.default
|
|
206
|
+
: DEFAULT_CLAWCLAMP_CONFIG.risk.default,
|
|
207
|
+
overrides: riskOverrides,
|
|
208
|
+
},
|
|
209
|
+
grants: {
|
|
210
|
+
defaultTtlSeconds:
|
|
211
|
+
raw.grants && typeof raw.grants === "object" && !Array.isArray(raw.grants)
|
|
212
|
+
? Number((raw.grants as Record<string, unknown>).defaultTtlSeconds) ||
|
|
213
|
+
DEFAULT_CLAWCLAMP_CONFIG.grants.defaultTtlSeconds
|
|
214
|
+
: DEFAULT_CLAWCLAMP_CONFIG.grants.defaultTtlSeconds,
|
|
215
|
+
maxTtlSeconds:
|
|
216
|
+
raw.grants && typeof raw.grants === "object" && !Array.isArray(raw.grants)
|
|
217
|
+
? Number((raw.grants as Record<string, unknown>).maxTtlSeconds) ||
|
|
218
|
+
DEFAULT_CLAWCLAMP_CONFIG.grants.maxTtlSeconds
|
|
219
|
+
: DEFAULT_CLAWCLAMP_CONFIG.grants.maxTtlSeconds,
|
|
220
|
+
},
|
|
221
|
+
audit: {
|
|
222
|
+
maxEntries:
|
|
223
|
+
raw.audit && typeof raw.audit === "object" && !Array.isArray(raw.audit)
|
|
224
|
+
? Number((raw.audit as Record<string, unknown>).maxEntries) ||
|
|
225
|
+
DEFAULT_CLAWCLAMP_CONFIG.audit.maxEntries
|
|
226
|
+
: DEFAULT_CLAWCLAMP_CONFIG.audit.maxEntries,
|
|
227
|
+
includeParams:
|
|
228
|
+
raw.audit && typeof raw.audit === "object" && !Array.isArray(raw.audit)
|
|
229
|
+
? (raw.audit as Record<string, unknown>).includeParams === true
|
|
230
|
+
: DEFAULT_CLAWCLAMP_CONFIG.audit.includeParams,
|
|
231
|
+
maxParamLength:
|
|
232
|
+
raw.audit && typeof raw.audit === "object" && !Array.isArray(raw.audit)
|
|
233
|
+
? Number((raw.audit as Record<string, unknown>).maxParamLength) ||
|
|
234
|
+
DEFAULT_CLAWCLAMP_CONFIG.audit.maxParamLength
|
|
235
|
+
: DEFAULT_CLAWCLAMP_CONFIG.audit.maxParamLength,
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
resolved.grants.defaultTtlSeconds = Math.min(
|
|
240
|
+
Math.max(60, resolved.grants.defaultTtlSeconds),
|
|
241
|
+
resolved.grants.maxTtlSeconds,
|
|
242
|
+
);
|
|
243
|
+
resolved.grants.maxTtlSeconds = Math.max(60, resolved.grants.maxTtlSeconds);
|
|
244
|
+
|
|
245
|
+
resolved.audit.maxEntries = Math.max(50, resolved.audit.maxEntries);
|
|
246
|
+
resolved.audit.maxParamLength = Math.max(256, resolved.audit.maxParamLength);
|
|
247
|
+
|
|
248
|
+
return resolved;
|
|
249
|
+
}
|
package/src/grants.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
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, GrantRecord } from "./types.js";
|
|
6
|
+
import { withStateFileLock } from "./storage.js";
|
|
7
|
+
import { buildDefaultPolicyStore } from "./policy.js";
|
|
8
|
+
|
|
9
|
+
const POLICY_FILE = "policy-store.json";
|
|
10
|
+
const POLICY_STORE_ID = "clawclamp";
|
|
11
|
+
const GRANT_POLICY_PREFIX = "grant:";
|
|
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?: 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
|
+
};
|
|
32
|
+
|
|
33
|
+
function resolvePolicyPath(stateDir: string): string {
|
|
34
|
+
return path.join(stateDir, "clawclamp", POLICY_FILE);
|
|
35
|
+
}
|
|
36
|
+
|
|
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;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function writePolicyStore(stateDir: string, store: PolicyStoreSnapshot): Promise<void> {
|
|
55
|
+
const filePath = resolvePolicyPath(stateDir);
|
|
56
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
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
|
+
};`;
|
|
82
|
+
}
|
|
83
|
+
|
|
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;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function listGrants(stateDir: string): Promise<GrantRecord[]> {
|
|
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);
|
|
130
|
+
}
|
|
131
|
+
return Object.entries(policyStore.policies ?? {})
|
|
132
|
+
.map(([id, record]) => parseGrantPolicy(id, record))
|
|
133
|
+
.filter((grant): grant is GrantRecord => Boolean(grant))
|
|
134
|
+
.sort(sortNewestFirst);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function createGrant(params: {
|
|
139
|
+
stateDir: string;
|
|
140
|
+
config: ClawClampConfig;
|
|
141
|
+
toolName: string;
|
|
142
|
+
ttlSeconds?: number;
|
|
143
|
+
note?: string;
|
|
144
|
+
}): Promise<GrantRecord> {
|
|
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();
|
|
149
|
+
const ttlSeconds = Math.min(
|
|
150
|
+
Math.max(60, params.ttlSeconds ?? params.config.grants.defaultTtlSeconds),
|
|
151
|
+
params.config.grants.maxTtlSeconds,
|
|
152
|
+
);
|
|
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,
|
|
164
|
+
toolName: params.toolName,
|
|
165
|
+
createdAt: new Date(nowMs).toISOString(),
|
|
166
|
+
expiresAt: new Date(expiresAtMs).toISOString(),
|
|
167
|
+
note: params.note?.trim() || undefined,
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function revokeGrant(stateDir: string, grantId: string): Promise<boolean> {
|
|
173
|
+
return withStateFileLock(stateDir, "policy-store", async () => {
|
|
174
|
+
const store = await readPolicyStore(stateDir);
|
|
175
|
+
const policyStore = getWritableStore(store);
|
|
176
|
+
if (!policyStore.policies?.[grantId]) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
delete policyStore.policies[grantId];
|
|
180
|
+
await writePolicyStore(stateDir, store);
|
|
181
|
+
return true;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { DEFAULT_CLAWCLAMP_CONFIG } from "./config.js";
|
|
6
|
+
import { createClawClampService } from "./guard.js";
|
|
7
|
+
import { readAuditEntries } from "./audit.js";
|
|
8
|
+
|
|
9
|
+
const getCedarlingMock = vi.fn();
|
|
10
|
+
|
|
11
|
+
vi.mock("./cedarling.js", () => ({
|
|
12
|
+
getCedarling: getCedarlingMock,
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
describe("ClawclampService", () => {
|
|
16
|
+
let stateDir: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
vi.clearAllMocks();
|
|
20
|
+
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawclamp-"));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
await fs.rm(stateDir, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("blocks tool execution when Cedar initialization fails and policyFailOpen is false", async () => {
|
|
28
|
+
getCedarlingMock.mockRejectedValue(new Error("fetch failed"));
|
|
29
|
+
|
|
30
|
+
const logger = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
|
|
31
|
+
const service = createClawClampService({
|
|
32
|
+
api: { logger } as any,
|
|
33
|
+
config: {
|
|
34
|
+
...DEFAULT_CLAWCLAMP_CONFIG,
|
|
35
|
+
mode: "enforce",
|
|
36
|
+
policyFailOpen: false,
|
|
37
|
+
},
|
|
38
|
+
stateDir,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const result = await service.handleBeforeToolCall(
|
|
42
|
+
{
|
|
43
|
+
toolName: "browser",
|
|
44
|
+
params: { action: "open", url: "https://example.com" },
|
|
45
|
+
toolCallId: "call-1",
|
|
46
|
+
runId: "run-1",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
sessionId: "session-1",
|
|
50
|
+
sessionKey: "agent:main:main",
|
|
51
|
+
agentId: "main",
|
|
52
|
+
} as any,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(result).toEqual({ block: true, blockReason: "fetch failed" });
|
|
56
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
57
|
+
"Clawclamp authorization failed for browser: fetch failed",
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const auditEntries = await readAuditEntries(stateDir, 10);
|
|
61
|
+
expect(auditEntries).toHaveLength(1);
|
|
62
|
+
expect(auditEntries[0]).toMatchObject({
|
|
63
|
+
toolName: "browser",
|
|
64
|
+
cedarDecision: "error",
|
|
65
|
+
decision: "error",
|
|
66
|
+
reason: "fetch failed",
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|