clawsecure 1.0.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.
@@ -0,0 +1,238 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+ const logger = require('./logger');
7
+
8
+ /**
9
+ * SHA-256 hash a single file.
10
+ * @param {string} filePath Absolute path
11
+ * @returns {string|null} Hex hash string, or null if file unreadable
12
+ */
13
+ function hashFile(filePath) {
14
+ try {
15
+ const content = fs.readFileSync(filePath);
16
+ return crypto.createHash('sha256').update(content).digest('hex');
17
+ } catch (err) {
18
+ logger.debug(`Cannot hash file ${filePath}: ${err.message}`);
19
+ return null;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Hash all files in a directory (non-recursive, top-level files only).
25
+ * Returns a combined hash representing the directory state.
26
+ * @param {string} dir Absolute directory path
27
+ * @returns {{ files: Map<string, string>, combined: string|null }}
28
+ */
29
+ function hashDirectory(dir) {
30
+ const fileHashes = new Map();
31
+
32
+ try {
33
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
34
+ for (const entry of entries) {
35
+ if (!entry.isFile()) continue;
36
+ if (entry.name.startsWith('.')) continue;
37
+ const fullPath = path.join(dir, entry.name);
38
+ const hash = hashFile(fullPath);
39
+ if (hash) {
40
+ fileHashes.set(fullPath, hash);
41
+ }
42
+ }
43
+ } catch (err) {
44
+ logger.debug(`Cannot read directory ${dir}: ${err.message}`);
45
+ return { files: fileHashes, combined: null };
46
+ }
47
+
48
+ // Combined hash from sorted file hashes
49
+ if (fileHashes.size === 0) return { files: fileHashes, combined: null };
50
+
51
+ const sorted = Array.from(fileHashes.entries()).sort((a, b) => a[0].localeCompare(b[0]));
52
+ const combinedInput = sorted.map(([p, h]) => `${p}:${h}`).join('\n');
53
+ const combined = crypto.createHash('sha256').update(combinedInput).digest('hex');
54
+
55
+ return { files: fileHashes, combined };
56
+ }
57
+
58
+ /**
59
+ * Extract name and description from SKILL.md YAML frontmatter.
60
+ * Uses simple regex to avoid a heavy YAML dependency.
61
+ * @param {string} filePath Path to SKILL.md
62
+ * @returns {{ name: string, description: string }|null}
63
+ */
64
+ function extractSkillFrontmatter(filePath) {
65
+ try {
66
+ const content = fs.readFileSync(filePath, 'utf-8');
67
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
68
+ if (!match) return null;
69
+
70
+ const frontmatter = match[1];
71
+ const name = extractYamlField(frontmatter, 'name');
72
+ const description = extractYamlField(frontmatter, 'description');
73
+
74
+ return {
75
+ name: name || path.basename(path.dirname(filePath)),
76
+ description: description || ''
77
+ };
78
+ } catch (err) {
79
+ logger.debug(`Cannot read frontmatter from ${filePath}: ${err.message}`);
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Extract a simple scalar YAML field value.
86
+ * Handles: `key: value` and `key: "quoted value"`
87
+ * @param {string} yaml Raw YAML string
88
+ * @param {string} field Field name
89
+ * @returns {string|null}
90
+ */
91
+ function extractYamlField(yaml, field) {
92
+ const regex = new RegExp(`^${field}:\\s*["']?(.+?)["']?\\s*$`, 'm');
93
+ const match = yaml.match(regex);
94
+ return match ? match[1].trim() : null;
95
+ }
96
+
97
+ /**
98
+ * Scan a single skill directory for SKILL.md and all component files.
99
+ * @param {string} dir Absolute path to a skill directory
100
+ * @returns {Array<object>} Discovered skill components
101
+ */
102
+ function scanSkillDirectory(dir) {
103
+ const skills = [];
104
+
105
+ if (!fs.existsSync(dir)) {
106
+ logger.debug(`Skill directory does not exist: ${dir}`);
107
+ return skills;
108
+ }
109
+
110
+ let entries;
111
+ try {
112
+ entries = fs.readdirSync(dir, { withFileTypes: true });
113
+ } catch (err) {
114
+ logger.debug(`Cannot read skill directory ${dir}: ${err.message}`);
115
+ return skills;
116
+ }
117
+
118
+ for (const entry of entries) {
119
+ if (!entry.isDirectory()) continue;
120
+ if (entry.name.startsWith('.')) continue;
121
+
122
+ const skillDir = path.join(dir, entry.name);
123
+ const skillMdPath = path.join(skillDir, 'SKILL.md');
124
+
125
+ // A valid skill has a SKILL.md file
126
+ if (!fs.existsSync(skillMdPath)) {
127
+ logger.debug(`No SKILL.md in ${skillDir}, skipping`);
128
+ continue;
129
+ }
130
+
131
+ const frontmatter = extractSkillFrontmatter(skillMdPath);
132
+ const dirHash = hashDirectory(skillDir);
133
+
134
+ skills.push({
135
+ name: frontmatter ? frontmatter.name : entry.name,
136
+ type: 'skill',
137
+ source: 'local',
138
+ enabled: true,
139
+ path: skillDir,
140
+ description: frontmatter ? frontmatter.description : '',
141
+ hash: dirHash.combined,
142
+ fileHashes: dirHash.files
143
+ });
144
+ }
145
+
146
+ return skills;
147
+ }
148
+
149
+ /**
150
+ * Run a full disk scan across all skill directories.
151
+ * Merges config-based inventory with on-disk discovery.
152
+ *
153
+ * @param {object} configInventory From config-parser.extractComponents()
154
+ * @param {string[]} skillDirs From config-parser.extractSkillDirs()
155
+ * @returns {{ components: Array<object>, snapshot: Map<string, string> }}
156
+ */
157
+ function scanAll(configInventory, skillDirs) {
158
+ const components = [];
159
+ const snapshot = new Map();
160
+
161
+ // 1. Scan skill directories on disk
162
+ for (const dir of skillDirs) {
163
+ const discovered = scanSkillDirectory(dir);
164
+ for (const skill of discovered) {
165
+ components.push(skill);
166
+ // Add all file hashes to snapshot
167
+ if (skill.fileHashes) {
168
+ for (const [filePath, hash] of skill.fileHashes) {
169
+ snapshot.set(filePath, hash);
170
+ }
171
+ }
172
+ }
173
+ }
174
+
175
+ // 2. Add config-based components (MCP servers, CLI tools, agents, plugins, repos)
176
+ // These don't have on-disk files to hash (they're config references)
177
+ for (const type of ['mcpServers', 'cliTools', 'agents', 'plugins', 'repos']) {
178
+ if (configInventory[type]) {
179
+ for (const item of configInventory[type]) {
180
+ components.push(Object.assign({}, item, { hash: null, fileHashes: null }));
181
+ }
182
+ }
183
+ }
184
+
185
+ // 3. Hash the openclaw.json config file itself
186
+ const configDir = require('./config-parser').getOpenClawDir();
187
+ const configFilePath = path.join(configDir, 'openclaw.json');
188
+ const configHash = hashFile(configFilePath);
189
+ if (configHash) {
190
+ snapshot.set(configFilePath, configHash);
191
+ }
192
+
193
+ logger.info(
194
+ `Scan complete: ${components.length} components, ${snapshot.size} files hashed`
195
+ );
196
+
197
+ return { components, snapshot };
198
+ }
199
+
200
+ /**
201
+ * Compute the delta between two snapshots.
202
+ * @param {Map<string, string>} previous Previous file hash snapshot
203
+ * @param {Map<string, string>} current Current file hash snapshot
204
+ * @returns {{ added: string[], changed: string[], removed: string[] }}
205
+ */
206
+ function computeDelta(previous, current) {
207
+ const added = [];
208
+ const changed = [];
209
+ const removed = [];
210
+
211
+ // Files in current but not previous = added
212
+ // Files in both but hash differs = changed
213
+ for (const [filePath, hash] of current) {
214
+ if (!previous.has(filePath)) {
215
+ added.push(filePath);
216
+ } else if (previous.get(filePath) !== hash) {
217
+ changed.push(filePath);
218
+ }
219
+ }
220
+
221
+ // Files in previous but not current = removed
222
+ for (const filePath of previous.keys()) {
223
+ if (!current.has(filePath)) {
224
+ removed.push(filePath);
225
+ }
226
+ }
227
+
228
+ return { added, changed, removed };
229
+ }
230
+
231
+ module.exports = {
232
+ hashFile,
233
+ hashDirectory,
234
+ extractSkillFrontmatter,
235
+ scanSkillDirectory,
236
+ scanAll,
237
+ computeDelta
238
+ };
@@ -0,0 +1,352 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const JSON5 = require('json5');
7
+ const logger = require('./logger');
8
+
9
+ const DEFAULT_CONFIG_DIR = path.join(os.homedir(), '.openclaw');
10
+ const DEFAULT_CONFIG_FILE = 'openclaw.json';
11
+
12
+ /**
13
+ * Resolve the path to openclaw.json.
14
+ * Priority: OPENCLAW_CONFIG_PATH env > --profile flag > default ~/.openclaw/openclaw.json
15
+ * @param {{ profile?: string | null }} opts
16
+ * @returns {string} Absolute path to config file
17
+ */
18
+ function findConfigPath(opts) {
19
+ // 1. Environment variable override
20
+ const envPath = process.env.OPENCLAW_CONFIG_PATH;
21
+ if (envPath) {
22
+ logger.debug(`Config path from OPENCLAW_CONFIG_PATH: ${envPath}`);
23
+ return path.resolve(envPath);
24
+ }
25
+
26
+ // 2. Profile flag (uses ~/.openclaw/profiles/<name>/openclaw.json)
27
+ if (opts && opts.profile) {
28
+ const profilePath = path.join(DEFAULT_CONFIG_DIR, 'profiles', opts.profile, DEFAULT_CONFIG_FILE);
29
+ logger.debug(`Config path from --profile "${opts.profile}": ${profilePath}`);
30
+ return profilePath;
31
+ }
32
+
33
+ // 3. Default
34
+ const defaultPath = path.join(DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_FILE);
35
+ logger.debug(`Config path (default): ${defaultPath}`);
36
+ return defaultPath;
37
+ }
38
+
39
+ /**
40
+ * Read and parse the openclaw.json file (JSON5 format).
41
+ * @param {string} filePath Absolute path to config file
42
+ * @returns {object} Parsed config object
43
+ * @throws {Error} If file not found or JSON5 parse fails
44
+ */
45
+ function parseConfig(filePath) {
46
+ if (!fs.existsSync(filePath)) {
47
+ throw new Error(
48
+ `OpenClaw config not found at ${filePath}. ` +
49
+ `Make sure OpenClaw is installed and configured. ` +
50
+ `You can set a custom path with the OPENCLAW_CONFIG_PATH environment variable.`
51
+ );
52
+ }
53
+
54
+ let raw;
55
+ try {
56
+ raw = fs.readFileSync(filePath, 'utf-8');
57
+ } catch (err) {
58
+ throw new Error(`Cannot read config file at ${filePath}: ${err.message}`);
59
+ }
60
+
61
+ try {
62
+ return JSON5.parse(raw);
63
+ } catch (err) {
64
+ throw new Error(`Invalid JSON5 in ${filePath}: ${err.message}`);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Extract all components from parsed config into a normalized inventory.
70
+ * Returns arrays of { name, source, type, enabled } objects.
71
+ * @param {object} config Parsed openclaw.json
72
+ * @returns {object} Component inventory
73
+ */
74
+ function extractComponents(config) {
75
+ const inventory = {
76
+ skills: extractSkills(config),
77
+ mcpServers: extractMcpServers(config),
78
+ cliTools: extractCliTools(config),
79
+ agents: extractAgents(config),
80
+ plugins: extractPlugins(config),
81
+ repos: extractRepos(config)
82
+ };
83
+
84
+ return inventory;
85
+ }
86
+
87
+ /**
88
+ * Extract skill entries from config.
89
+ * Skills can be in config.skills (managed), config.skills.load.extraDirs, etc.
90
+ * @param {object} config
91
+ * @returns {Array<object>}
92
+ */
93
+ function extractSkills(config) {
94
+ const skills = [];
95
+
96
+ // Skills listed directly in config
97
+ if (config.skills && typeof config.skills === 'object') {
98
+ // skills can be an array or an object with named entries
99
+ if (Array.isArray(config.skills)) {
100
+ for (const skill of config.skills) {
101
+ skills.push(normalizeComponent(skill, 'skill'));
102
+ }
103
+ } else {
104
+ // Object format: { "skill-name": { ... } }
105
+ for (const [name, value] of Object.entries(config.skills)) {
106
+ if (name === 'load') continue; // skip the load config block
107
+ const entry = typeof value === 'object' && value !== null ? value : {};
108
+ skills.push(normalizeComponent({ name, ...entry }, 'skill'));
109
+ }
110
+ }
111
+ }
112
+
113
+ return skills;
114
+ }
115
+
116
+ /**
117
+ * Extract MCP server configurations.
118
+ * Looks in config.mcpServers or config.mcp.servers (per-agent or global).
119
+ * @param {object} config
120
+ * @returns {Array<object>}
121
+ */
122
+ function extractMcpServers(config) {
123
+ const servers = [];
124
+
125
+ // Global mcpServers
126
+ const globalServers = config.mcpServers || (config.mcp && config.mcp.servers) || {};
127
+ if (typeof globalServers === 'object' && !Array.isArray(globalServers)) {
128
+ for (const [name, value] of Object.entries(globalServers)) {
129
+ const entry = typeof value === 'object' && value !== null ? value : {};
130
+ servers.push({
131
+ name,
132
+ type: 'mcp_server',
133
+ source: entry.command || entry.url || 'unknown',
134
+ enabled: entry.disabled !== true,
135
+ serverType: classifyMcpServerType(entry),
136
+ accessScope: entry.command || ''
137
+ });
138
+ }
139
+ }
140
+
141
+ // Per-agent MCP servers
142
+ if (config.agents && typeof config.agents === 'object') {
143
+ for (const [agentName, agentConfig] of Object.entries(config.agents)) {
144
+ const agentServers = agentConfig.mcpServers || (agentConfig.mcp && agentConfig.mcp.servers) || {};
145
+ if (typeof agentServers === 'object' && !Array.isArray(agentServers)) {
146
+ for (const [name, value] of Object.entries(agentServers)) {
147
+ const entry = typeof value === 'object' && value !== null ? value : {};
148
+ // Only add if not already in global list
149
+ if (!servers.find((s) => s.name === name)) {
150
+ servers.push({
151
+ name,
152
+ type: 'mcp_server',
153
+ source: entry.command || entry.url || 'unknown',
154
+ enabled: entry.disabled !== true,
155
+ serverType: classifyMcpServerType(entry),
156
+ accessScope: entry.command || '',
157
+ agent: agentName
158
+ });
159
+ }
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ return servers;
166
+ }
167
+
168
+ /**
169
+ * Classify MCP server type based on config.
170
+ * @param {object} entry MCP server config entry
171
+ * @returns {string} npx | docker | sse | stdio | unknown
172
+ */
173
+ function classifyMcpServerType(entry) {
174
+ if (!entry || !entry.command) return 'unknown';
175
+ const cmd = String(entry.command);
176
+ if (cmd.includes('npx') || cmd.includes('npm')) return 'npx';
177
+ if (cmd.includes('docker')) return 'docker';
178
+ if (entry.url) return 'sse';
179
+ return 'stdio';
180
+ }
181
+
182
+ /**
183
+ * Extract CLI tool references from config.
184
+ * Looks in config.tools and skill metadata for required bins.
185
+ * @param {object} config
186
+ * @returns {Array<object>}
187
+ */
188
+ function extractCliTools(config) {
189
+ const tools = [];
190
+
191
+ if (config.tools && typeof config.tools === 'object') {
192
+ for (const [name, value] of Object.entries(config.tools)) {
193
+ const entry = typeof value === 'object' && value !== null ? value : {};
194
+ tools.push(normalizeComponent({ name, ...entry }, 'cli_tool'));
195
+ }
196
+ }
197
+
198
+ return tools;
199
+ }
200
+
201
+ /**
202
+ * Extract agent configurations.
203
+ * @param {object} config
204
+ * @returns {Array<object>}
205
+ */
206
+ function extractAgents(config) {
207
+ const agents = [];
208
+
209
+ if (config.agents && typeof config.agents === 'object') {
210
+ for (const [name, value] of Object.entries(config.agents)) {
211
+ const entry = typeof value === 'object' && value !== null ? value : {};
212
+ agents.push({
213
+ name,
214
+ type: 'agent',
215
+ source: 'local',
216
+ enabled: entry.disabled !== true
217
+ });
218
+ }
219
+ }
220
+
221
+ return agents;
222
+ }
223
+
224
+ /**
225
+ * Extract plugin entries from config.
226
+ * @param {object} config
227
+ * @returns {Array<object>}
228
+ */
229
+ function extractPlugins(config) {
230
+ const plugins = [];
231
+
232
+ if (config.plugins && typeof config.plugins === 'object') {
233
+ const items = Array.isArray(config.plugins) ? config.plugins : Object.entries(config.plugins);
234
+ for (const item of items) {
235
+ if (Array.isArray(item)) {
236
+ const [name, value] = item;
237
+ const entry = typeof value === 'object' && value !== null ? value : {};
238
+ plugins.push(normalizeComponent({ name, ...entry }, 'plugin'));
239
+ } else if (typeof item === 'object') {
240
+ plugins.push(normalizeComponent(item, 'plugin'));
241
+ }
242
+ }
243
+ }
244
+
245
+ return plugins;
246
+ }
247
+
248
+ /**
249
+ * Extract repo references from config.
250
+ * @param {object} config
251
+ * @returns {Array<object>}
252
+ */
253
+ function extractRepos(config) {
254
+ const repos = [];
255
+
256
+ if (config.repos && typeof config.repos === 'object') {
257
+ const items = Array.isArray(config.repos) ? config.repos : Object.entries(config.repos);
258
+ for (const item of items) {
259
+ if (Array.isArray(item)) {
260
+ const [name, value] = item;
261
+ const entry = typeof value === 'object' && value !== null ? value : {};
262
+ repos.push(normalizeComponent({ name, ...entry }, 'repo'));
263
+ } else if (typeof item === 'string') {
264
+ repos.push({ name: item, type: 'repo', source: item, enabled: true });
265
+ } else if (typeof item === 'object') {
266
+ repos.push(normalizeComponent(item, 'repo'));
267
+ }
268
+ }
269
+ }
270
+
271
+ return repos;
272
+ }
273
+
274
+ /**
275
+ * Normalize a component entry to a standard shape.
276
+ * @param {object} entry Raw config entry
277
+ * @param {string} type Component type
278
+ * @returns {{ name: string, type: string, source: string, enabled: boolean }}
279
+ */
280
+ function normalizeComponent(entry, type) {
281
+ return {
282
+ name: entry.name || entry.id || 'unnamed',
283
+ type,
284
+ source: entry.source || entry.url || entry.repo || entry.package || 'local',
285
+ enabled: entry.disabled !== true && entry.enabled !== false
286
+ };
287
+ }
288
+
289
+ /**
290
+ * Get all directories that should be watched for skill files.
291
+ * @param {object} config Parsed openclaw.json
292
+ * @returns {string[]} Array of absolute directory paths
293
+ */
294
+ function extractSkillDirs(config) {
295
+ const dirs = [];
296
+
297
+ // Default shared skills directory
298
+ const defaultSkillsDir = path.join(DEFAULT_CONFIG_DIR, 'skills');
299
+ dirs.push(defaultSkillsDir);
300
+
301
+ // Extra skill directories from config
302
+ if (config.skills && config.skills.load && Array.isArray(config.skills.load.extraDirs)) {
303
+ for (const dir of config.skills.load.extraDirs) {
304
+ dirs.push(path.resolve(dir));
305
+ }
306
+ }
307
+
308
+ // Per-agent workspace skill directories
309
+ if (config.agents && typeof config.agents === 'object') {
310
+ for (const [, agentConfig] of Object.entries(config.agents)) {
311
+ if (agentConfig.workspace) {
312
+ const workspaceSkills = path.join(path.resolve(agentConfig.workspace), 'skills');
313
+ dirs.push(workspaceSkills);
314
+ }
315
+ }
316
+ }
317
+
318
+ // Deduplicate
319
+ return [...new Set(dirs)];
320
+ }
321
+
322
+ /**
323
+ * Get the base OpenClaw directory path.
324
+ * @returns {string}
325
+ */
326
+ function getOpenClawDir() {
327
+ return process.env.OPENCLAW_CONFIG_PATH
328
+ ? path.dirname(process.env.OPENCLAW_CONFIG_PATH)
329
+ : DEFAULT_CONFIG_DIR;
330
+ }
331
+
332
+ /**
333
+ * Count total components in an inventory.
334
+ * @param {object} inventory From extractComponents()
335
+ * @returns {number}
336
+ */
337
+ function countComponents(inventory) {
338
+ let total = 0;
339
+ for (const key of Object.keys(inventory)) {
340
+ total += inventory[key].length;
341
+ }
342
+ return total;
343
+ }
344
+
345
+ module.exports = {
346
+ findConfigPath,
347
+ parseConfig,
348
+ extractComponents,
349
+ extractSkillDirs,
350
+ getOpenClawDir,
351
+ countComponents
352
+ };