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,220 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const logger = require('./logger');
7
+
8
+ const CLAWSECURE_DIR = path.join(os.homedir(), '.clawsecure');
9
+ const PID_FILE = path.join(CLAWSECURE_DIR, 'daemon.pid');
10
+ const CONFIG_FILE = path.join(CLAWSECURE_DIR, 'config.json');
11
+
12
+ /**
13
+ * Ensure ~/.clawsecure/ directory exists with secure permissions.
14
+ */
15
+ function ensureConfigDir() {
16
+ if (!fs.existsSync(CLAWSECURE_DIR)) {
17
+ fs.mkdirSync(CLAWSECURE_DIR, { recursive: true, mode: 0o700 });
18
+ logger.debug(`Created config directory: ${CLAWSECURE_DIR}`);
19
+ }
20
+ }
21
+
22
+ // --- PID File Management ---
23
+
24
+ /**
25
+ * Write the current process PID to the PID file.
26
+ * @returns {boolean}
27
+ */
28
+ function writePid() {
29
+ ensureConfigDir();
30
+ try {
31
+ fs.writeFileSync(PID_FILE, String(process.pid), { mode: 0o600 });
32
+ logger.debug(`PID file written: ${PID_FILE} (${process.pid})`);
33
+ return true;
34
+ } catch (err) {
35
+ logger.error(`Cannot write PID file: ${err.message}`);
36
+ return false;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Read the PID from the PID file.
42
+ * @returns {number|null}
43
+ */
44
+ function readPid() {
45
+ try {
46
+ if (!fs.existsSync(PID_FILE)) return null;
47
+ const raw = fs.readFileSync(PID_FILE, 'utf-8').trim();
48
+ const pid = parseInt(raw, 10);
49
+ return isNaN(pid) ? null : pid;
50
+ } catch (err) {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Remove the PID file.
57
+ */
58
+ function removePid() {
59
+ try {
60
+ if (fs.existsSync(PID_FILE)) {
61
+ fs.unlinkSync(PID_FILE);
62
+ logger.debug('PID file removed');
63
+ }
64
+ } catch (err) {
65
+ logger.debug(`Cannot remove PID file: ${err.message}`);
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Check if a process with the given PID is running.
71
+ * @param {number} pid
72
+ * @returns {boolean}
73
+ */
74
+ function isProcessRunning(pid) {
75
+ try {
76
+ process.kill(pid, 0); // Signal 0 = existence check, no actual signal sent
77
+ return true;
78
+ } catch (err) {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Check if another daemon instance is already running.
85
+ * Cleans up stale PID files.
86
+ * @returns {{ running: boolean, pid: number|null }}
87
+ */
88
+ function checkExisting() {
89
+ const pid = readPid();
90
+ if (pid === null) {
91
+ return { running: false, pid: null };
92
+ }
93
+
94
+ if (isProcessRunning(pid)) {
95
+ return { running: true, pid };
96
+ }
97
+
98
+ // Stale PID file, clean up
99
+ logger.debug(`Stale PID file found (process ${pid} not running), removing`);
100
+ removePid();
101
+ return { running: false, pid: null };
102
+ }
103
+
104
+ /**
105
+ * Send SIGTERM to a running daemon process.
106
+ * @param {number} pid
107
+ * @returns {boolean}
108
+ */
109
+ function stopProcess(pid) {
110
+ try {
111
+ process.kill(pid, 'SIGTERM');
112
+ return true;
113
+ } catch (err) {
114
+ logger.error(`Cannot stop process ${pid}: ${err.message}`);
115
+ return false;
116
+ }
117
+ }
118
+
119
+ // --- Token / Config Storage ---
120
+
121
+ /**
122
+ * Read the daemon config file (~/.clawsecure/config.json).
123
+ * @returns {object} Config object (may be empty)
124
+ */
125
+ function readConfig() {
126
+ try {
127
+ if (!fs.existsSync(CONFIG_FILE)) return {};
128
+ const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
129
+ return JSON.parse(raw);
130
+ } catch (err) {
131
+ logger.debug(`Cannot read config: ${err.message}`);
132
+ return {};
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Write the daemon config file with secure permissions.
138
+ * @param {object} config
139
+ * @returns {boolean}
140
+ */
141
+ function writeConfig(config) {
142
+ ensureConfigDir();
143
+ try {
144
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
145
+ logger.debug(`Config written to ${CONFIG_FILE}`);
146
+ return true;
147
+ } catch (err) {
148
+ logger.error(`Cannot write config: ${err.message}`);
149
+ return false;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Get the daemon API token.
155
+ * Priority: CLAWSECURE_TOKEN env var > config file > null
156
+ * @returns {string|null}
157
+ */
158
+ function getToken() {
159
+ // 1. Environment variable
160
+ const envToken = process.env.CLAWSECURE_TOKEN;
161
+ if (envToken && envToken.trim()) {
162
+ logger.debug('Using token from CLAWSECURE_TOKEN environment variable');
163
+ return envToken.trim();
164
+ }
165
+
166
+ // 2. Config file
167
+ const config = readConfig();
168
+ if (config.token && config.token.trim()) {
169
+ logger.debug('Using token from config file');
170
+ return config.token.trim();
171
+ }
172
+
173
+ return null;
174
+ }
175
+
176
+ /**
177
+ * Save a token to the config file.
178
+ * @param {string} token
179
+ * @returns {boolean}
180
+ */
181
+ function saveToken(token) {
182
+ const config = readConfig();
183
+ config.token = token;
184
+ return writeConfig(config);
185
+ }
186
+
187
+ /**
188
+ * Get the API base URL.
189
+ * Priority: CLAWSECURE_API_URL env var > config file > default
190
+ * @returns {string}
191
+ */
192
+ function getApiUrl() {
193
+ const envUrl = process.env.CLAWSECURE_API_URL;
194
+ if (envUrl && envUrl.trim()) {
195
+ return envUrl.trim().replace(/\/$/, '');
196
+ }
197
+
198
+ const config = readConfig();
199
+ if (config.apiUrl && config.apiUrl.trim()) {
200
+ return config.apiUrl.trim().replace(/\/$/, '');
201
+ }
202
+
203
+ return 'https://api.clawsecure.ai';
204
+ }
205
+
206
+ module.exports = {
207
+ writePid,
208
+ readPid,
209
+ removePid,
210
+ checkExisting,
211
+ stopProcess,
212
+ readConfig,
213
+ writeConfig,
214
+ getToken,
215
+ saveToken,
216
+ getApiUrl,
217
+ CLAWSECURE_DIR,
218
+ PID_FILE,
219
+ CONFIG_FILE
220
+ };
@@ -0,0 +1,241 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const logger = require('./logger');
6
+
7
+ /**
8
+ * Discover agent directories under ~/.openclaw/agents/.
9
+ * @param {string} openclawDir Path to ~/.openclaw/
10
+ * @returns {Array<{ id: string, path: string }>} Agent IDs and paths
11
+ */
12
+ function discoverAgents(openclawDir) {
13
+ const agentsDir = path.join(openclawDir, 'agents');
14
+ const agents = [];
15
+
16
+ if (!fs.existsSync(agentsDir)) {
17
+ logger.debug(`No agents directory at ${agentsDir}`);
18
+ return agents;
19
+ }
20
+
21
+ try {
22
+ const entries = fs.readdirSync(agentsDir, { withFileTypes: true });
23
+ for (const entry of entries) {
24
+ if (!entry.isDirectory()) continue;
25
+ if (entry.name.startsWith('.')) continue;
26
+ agents.push({
27
+ id: entry.name,
28
+ path: path.join(agentsDir, entry.name)
29
+ });
30
+ }
31
+ } catch (err) {
32
+ logger.error(`Cannot read agents directory: ${err.message}`);
33
+ }
34
+
35
+ return agents;
36
+ }
37
+
38
+ /**
39
+ * Discover session JSONL files for a given agent.
40
+ * @param {string} agentDir Path to agent directory
41
+ * @returns {Array<{ id: string, path: string }>} Session IDs and file paths
42
+ */
43
+ function discoverSessions(agentDir) {
44
+ const sessionsDir = path.join(agentDir, 'sessions');
45
+ const sessions = [];
46
+
47
+ if (!fs.existsSync(sessionsDir)) {
48
+ logger.debug(`No sessions directory at ${sessionsDir}`);
49
+ return sessions;
50
+ }
51
+
52
+ try {
53
+ const entries = fs.readdirSync(sessionsDir, { withFileTypes: true });
54
+ for (const entry of entries) {
55
+ if (!entry.isFile()) continue;
56
+ if (!entry.name.endsWith('.jsonl')) continue;
57
+ const sessionId = entry.name.replace('.jsonl', '');
58
+ sessions.push({
59
+ id: sessionId,
60
+ path: path.join(sessionsDir, entry.name)
61
+ });
62
+ }
63
+ } catch (err) {
64
+ logger.debug(`Cannot read sessions directory: ${err.message}`);
65
+ }
66
+
67
+ return sessions;
68
+ }
69
+
70
+ /**
71
+ * Extract toolCall entries from a single JSONL line.
72
+ * Returns an array of { toolName, timestamp } objects.
73
+ *
74
+ * Expected JSONL format:
75
+ * { "timestamp": "...", "message": { "role": "...", "content": [{ "type": "toolCall", "name": "..." }] } }
76
+ *
77
+ * @param {string} line Single JSONL line
78
+ * @returns {Array<{ toolName: string, timestamp: string }>}
79
+ */
80
+ function extractToolCalls(line) {
81
+ const trimmed = line.trim();
82
+ if (!trimmed) return [];
83
+
84
+ let entry;
85
+ try {
86
+ entry = JSON.parse(trimmed);
87
+ } catch (err) {
88
+ // Malformed line, skip silently
89
+ return [];
90
+ }
91
+
92
+ const results = [];
93
+ const timestamp = entry.timestamp || null;
94
+ const content = entry.message && entry.message.content;
95
+
96
+ if (!Array.isArray(content)) return results;
97
+
98
+ for (const item of content) {
99
+ if (item && item.type === 'toolCall' && item.name) {
100
+ results.push({
101
+ toolName: item.name,
102
+ timestamp: timestamp
103
+ });
104
+ }
105
+ }
106
+
107
+ return results;
108
+ }
109
+
110
+ /**
111
+ * Parse a session JSONL file from a given byte offset (tail-like behavior).
112
+ * Returns extracted tool calls and the new file offset.
113
+ *
114
+ * @param {string} filePath Absolute path to .jsonl file
115
+ * @param {number} [fromOffset=0] Byte offset to start reading from
116
+ * @returns {{ toolCalls: Array<{ toolName: string, timestamp: string }>, newOffset: number }}
117
+ */
118
+ function parseSessionFile(filePath, fromOffset) {
119
+ const result = { toolCalls: [], newOffset: fromOffset || 0 };
120
+
121
+ let stat;
122
+ try {
123
+ stat = fs.statSync(filePath);
124
+ } catch (err) {
125
+ logger.debug(`Cannot stat session file ${filePath}: ${err.message}`);
126
+ return result;
127
+ }
128
+
129
+ // Nothing new to read
130
+ if (stat.size <= result.newOffset) {
131
+ return result;
132
+ }
133
+
134
+ // Handle file truncation (e.g., session reset)
135
+ if (stat.size < result.newOffset) {
136
+ logger.debug(`Session file ${filePath} was truncated, reading from start`);
137
+ result.newOffset = 0;
138
+ }
139
+
140
+ let content;
141
+ try {
142
+ const fd = fs.openSync(filePath, 'r');
143
+ const buffer = Buffer.alloc(stat.size - result.newOffset);
144
+ fs.readSync(fd, buffer, 0, buffer.length, result.newOffset);
145
+ fs.closeSync(fd);
146
+ content = buffer.toString('utf-8');
147
+ } catch (err) {
148
+ logger.debug(`Cannot read session file ${filePath}: ${err.message}`);
149
+ return result;
150
+ }
151
+
152
+ // Process each line
153
+ const lines = content.split('\n');
154
+ for (const line of lines) {
155
+ const calls = extractToolCalls(line);
156
+ result.toolCalls.push(...calls);
157
+ }
158
+
159
+ result.newOffset = stat.size;
160
+ return result;
161
+ }
162
+
163
+ /**
164
+ * Scan all agents and sessions, extract all tool call data.
165
+ * Tracks read offsets per file for incremental reads.
166
+ *
167
+ * @param {string} openclawDir Path to ~/.openclaw/
168
+ * @param {Map<string, number>} [offsets] Previous read offsets (filepath -> byte offset)
169
+ * @returns {{ toolCalls: Array<object>, offsets: Map<string, number>, stats: object }}
170
+ */
171
+ function scanAllSessions(openclawDir, offsets) {
172
+ const currentOffsets = offsets || new Map();
173
+ const allToolCalls = [];
174
+ let totalFiles = 0;
175
+ let totalAgents = 0;
176
+
177
+ const agents = discoverAgents(openclawDir);
178
+ totalAgents = agents.length;
179
+
180
+ for (const agent of agents) {
181
+ const sessions = discoverSessions(agent.path);
182
+ totalFiles += sessions.length;
183
+
184
+ for (const session of sessions) {
185
+ const prevOffset = currentOffsets.get(session.path) || 0;
186
+ const result = parseSessionFile(session.path, prevOffset);
187
+
188
+ // Tag each tool call with agent and session context
189
+ for (const tc of result.toolCalls) {
190
+ allToolCalls.push({
191
+ toolName: tc.toolName,
192
+ timestamp: tc.timestamp,
193
+ agentId: agent.id,
194
+ sessionId: session.id
195
+ });
196
+ }
197
+
198
+ currentOffsets.set(session.path, result.newOffset);
199
+ }
200
+ }
201
+
202
+ logger.info(
203
+ `Session scan: ${totalAgents} agent${totalAgents === 1 ? '' : 's'}, ` +
204
+ `${totalFiles} session file${totalFiles === 1 ? '' : 's'}, ` +
205
+ `${allToolCalls.length} tool call${allToolCalls.length === 1 ? '' : 's'} extracted`
206
+ );
207
+
208
+ return {
209
+ toolCalls: allToolCalls,
210
+ offsets: currentOffsets,
211
+ stats: { agents: totalAgents, files: totalFiles, toolCalls: allToolCalls.length }
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Get all session directories that should be watched.
217
+ * @param {string} openclawDir Path to ~/.openclaw/
218
+ * @returns {string[]} Array of session directory paths
219
+ */
220
+ function getSessionDirs(openclawDir) {
221
+ const dirs = [];
222
+ const agents = discoverAgents(openclawDir);
223
+
224
+ for (const agent of agents) {
225
+ const sessionsDir = path.join(agent.path, 'sessions');
226
+ if (fs.existsSync(sessionsDir)) {
227
+ dirs.push(sessionsDir);
228
+ }
229
+ }
230
+
231
+ return dirs;
232
+ }
233
+
234
+ module.exports = {
235
+ discoverAgents,
236
+ discoverSessions,
237
+ extractToolCalls,
238
+ parseSessionFile,
239
+ scanAllSessions,
240
+ getSessionDirs
241
+ };
@@ -0,0 +1,199 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const readline = require('readline');
6
+ const os = require('os');
7
+ const logger = require('./logger');
8
+
9
+ const OPENCLAW_DIR = path.join(os.homedir(), '.openclaw');
10
+ const SKILLS_DIR = path.join(OPENCLAW_DIR, 'skills');
11
+ const INSTALL_DIR = path.join(SKILLS_DIR, 'clawsecure');
12
+ const VERSION_FILE = '.clawsecure-version';
13
+ const BUNDLED_SKILL_DIR = path.join(__dirname, '..', 'skill');
14
+
15
+ /**
16
+ * Get the bundled skill version from the npm package.
17
+ * @returns {string|null}
18
+ */
19
+ function getBundledVersion() {
20
+ const versionPath = path.join(BUNDLED_SKILL_DIR, VERSION_FILE);
21
+ try {
22
+ return fs.readFileSync(versionPath, 'utf8').trim();
23
+ } catch (err) {
24
+ logger.warn('Could not read bundled skill version: ' + err.message);
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Get the installed skill version from the user's OpenClaw directory.
31
+ * @returns {string|null}
32
+ */
33
+ function getInstalledVersion() {
34
+ const versionPath = path.join(INSTALL_DIR, VERSION_FILE);
35
+ try {
36
+ return fs.readFileSync(versionPath, 'utf8').trim();
37
+ } catch (err) {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Compare two semver-like version strings.
44
+ * @param {string} a
45
+ * @param {string} b
46
+ * @returns {number} 1 if a > b, -1 if a < b, 0 if equal
47
+ */
48
+ function compareVersions(a, b) {
49
+ const partsA = a.split('.').map(Number);
50
+ const partsB = b.split('.').map(Number);
51
+ for (let i = 0; i < 3; i++) {
52
+ const na = partsA[i] || 0;
53
+ const nb = partsB[i] || 0;
54
+ if (na > nb) return 1;
55
+ if (na < nb) return -1;
56
+ }
57
+ return 0;
58
+ }
59
+
60
+ /**
61
+ * Copy all bundled skill files to the install directory.
62
+ * Preserves directory structure (references/ subdirectory).
63
+ */
64
+ function copySkillFiles() {
65
+ if (!fs.existsSync(INSTALL_DIR)) {
66
+ fs.mkdirSync(INSTALL_DIR, { recursive: true });
67
+ }
68
+
69
+ const refsSource = path.join(BUNDLED_SKILL_DIR, 'references');
70
+ const refsDest = path.join(INSTALL_DIR, 'references');
71
+ if (!fs.existsSync(refsDest)) {
72
+ fs.mkdirSync(refsDest, { recursive: true });
73
+ }
74
+
75
+ // Copy top-level skill files
76
+ const topFiles = fs.readdirSync(BUNDLED_SKILL_DIR).filter(f => {
77
+ const full = path.join(BUNDLED_SKILL_DIR, f);
78
+ return fs.statSync(full).isFile();
79
+ });
80
+
81
+ for (const file of topFiles) {
82
+ fs.copyFileSync(
83
+ path.join(BUNDLED_SKILL_DIR, file),
84
+ path.join(INSTALL_DIR, file)
85
+ );
86
+ }
87
+
88
+ // Copy references/ files
89
+ if (fs.existsSync(refsSource)) {
90
+ const refFiles = fs.readdirSync(refsSource).filter(f => {
91
+ return fs.statSync(path.join(refsSource, f)).isFile();
92
+ });
93
+
94
+ for (const file of refFiles) {
95
+ fs.copyFileSync(
96
+ path.join(refsSource, file),
97
+ path.join(refsDest, file)
98
+ );
99
+ }
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Prompt the user for Y/n input. Returns true for Y/Enter, false for n.
105
+ * Times out after 10 seconds and returns false (never blocks startup).
106
+ */
107
+ function promptUser(question) {
108
+ return new Promise((resolve) => {
109
+ const rl = readline.createInterface({
110
+ input: process.stdin,
111
+ output: process.stdout
112
+ });
113
+
114
+ const timeout = setTimeout(() => {
115
+ rl.close();
116
+ resolve(false);
117
+ }, 10000);
118
+
119
+ rl.question(question, (answer) => {
120
+ clearTimeout(timeout);
121
+ rl.close();
122
+ const trimmed = answer.trim().toLowerCase();
123
+ resolve(trimmed === '' || trimmed === 'y' || trimmed === 'yes');
124
+ });
125
+ });
126
+ }
127
+
128
+ /**
129
+ * Main entry point. Checks for OpenClaw installation and installs/updates
130
+ * the Claw security skill as needed.
131
+ *
132
+ * Never throws. Never blocks daemon startup on failure.
133
+ * @returns {Promise<void>}
134
+ */
135
+ async function installSkill() {
136
+ try {
137
+ // Check if OpenClaw is installed
138
+ if (!fs.existsSync(OPENCLAW_DIR)) {
139
+ logger.debug('OpenClaw directory not found at ' + OPENCLAW_DIR + '. Skipping skill install.');
140
+ return;
141
+ }
142
+
143
+ // Ensure skills directory exists
144
+ if (!fs.existsSync(SKILLS_DIR)) {
145
+ fs.mkdirSync(SKILLS_DIR, { recursive: true });
146
+ logger.debug('Created skills directory: ' + SKILLS_DIR);
147
+ }
148
+
149
+ const bundledVersion = getBundledVersion();
150
+ if (!bundledVersion) {
151
+ logger.warn('Bundled skill version unavailable. Skipping skill install.');
152
+ return;
153
+ }
154
+
155
+ // First install: directory does not exist
156
+ if (!fs.existsSync(INSTALL_DIR)) {
157
+ copySkillFiles();
158
+ logger.info('Claw security skill installed (v' + bundledVersion + ') to ' + INSTALL_DIR);
159
+ return;
160
+ }
161
+
162
+ // Directory exists but no version file: user-created, do not overwrite
163
+ const installedVersion = getInstalledVersion();
164
+ if (!installedVersion) {
165
+ logger.debug('Skill directory exists without version file. Skipping to avoid overwriting user content.');
166
+ return;
167
+ }
168
+
169
+ // Compare versions
170
+ const cmp = compareVersions(bundledVersion, installedVersion);
171
+
172
+ if (cmp === 0) {
173
+ logger.debug('Claw security skill is up to date (v' + installedVersion + ').');
174
+ return;
175
+ }
176
+
177
+ if (cmp < 0) {
178
+ logger.debug('Installed skill (v' + installedVersion + ') is newer than bundled (v' + bundledVersion + '). Skipping.');
179
+ return;
180
+ }
181
+
182
+ // Bundled is newer, prompt for update
183
+ const shouldUpdate = await promptUser(
184
+ 'A new version of the Claw security skill is available (v' +
185
+ installedVersion + ' -> v' + bundledVersion + '). Update? (Y/n) '
186
+ );
187
+
188
+ if (shouldUpdate) {
189
+ copySkillFiles();
190
+ logger.info('Claw security skill updated to v' + bundledVersion + '.');
191
+ } else {
192
+ logger.info('Skill update skipped. Continuing with v' + installedVersion + '.');
193
+ }
194
+ } catch (err) {
195
+ logger.warn('Skill install check failed: ' + err.message + '. Continuing without skill update.');
196
+ }
197
+ }
198
+
199
+ module.exports = { installSkill };