clawmoat 0.2.1

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.
Files changed (44) hide show
  1. package/CONTRIBUTING.md +56 -0
  2. package/LICENSE +21 -0
  3. package/README.md +199 -0
  4. package/bin/clawmoat.js +407 -0
  5. package/docs/CNAME +1 -0
  6. package/docs/MIT-RISK-GAP-ANALYSIS.md +146 -0
  7. package/docs/badge/score-A.svg +21 -0
  8. package/docs/badge/score-Aplus.svg +21 -0
  9. package/docs/badge/score-B.svg +21 -0
  10. package/docs/badge/score-C.svg +21 -0
  11. package/docs/badge/score-D.svg +21 -0
  12. package/docs/badge/score-F.svg +21 -0
  13. package/docs/blog/index.html +90 -0
  14. package/docs/blog/owasp-agentic-ai-top10.html +187 -0
  15. package/docs/blog/owasp-agentic-ai-top10.md +185 -0
  16. package/docs/blog/securing-ai-agents.html +194 -0
  17. package/docs/blog/securing-ai-agents.md +152 -0
  18. package/docs/compare.html +312 -0
  19. package/docs/index.html +654 -0
  20. package/docs/integrations/langchain.html +281 -0
  21. package/docs/integrations/openai.html +302 -0
  22. package/docs/integrations/openclaw.html +310 -0
  23. package/docs/robots.txt +3 -0
  24. package/docs/sitemap.xml +28 -0
  25. package/docs/thanks.html +79 -0
  26. package/package.json +35 -0
  27. package/server/Dockerfile +7 -0
  28. package/server/index.js +85 -0
  29. package/server/package.json +12 -0
  30. package/skill/SKILL.md +56 -0
  31. package/src/badge.js +87 -0
  32. package/src/index.js +316 -0
  33. package/src/middleware/openclaw.js +133 -0
  34. package/src/policies/engine.js +180 -0
  35. package/src/scanners/exfiltration.js +97 -0
  36. package/src/scanners/jailbreak.js +81 -0
  37. package/src/scanners/memory-poison.js +68 -0
  38. package/src/scanners/pii.js +128 -0
  39. package/src/scanners/prompt-injection.js +138 -0
  40. package/src/scanners/secrets.js +97 -0
  41. package/src/scanners/supply-chain.js +155 -0
  42. package/src/scanners/urls.js +142 -0
  43. package/src/utils/config.js +137 -0
  44. package/src/utils/logger.js +109 -0
package/src/badge.js ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * ClawMoat Security Score Badge Generator
3
+ *
4
+ * Generates shields.io-style SVG badges based on audit/scan results.
5
+ */
6
+
7
+ const GRADES = {
8
+ 'A+': { color: '#10B981', label: 'excellent' },
9
+ 'A': { color: '#10B981', label: 'great' },
10
+ 'B': { color: '#84CC16', label: 'good' },
11
+ 'C': { color: '#F59E0B', label: 'fair' },
12
+ 'D': { color: '#EF4444', label: 'poor' },
13
+ 'F': { color: '#DC2626', label: 'failing' },
14
+ };
15
+
16
+ /**
17
+ * Calculate security grade from scan/audit findings.
18
+ * @param {object} opts
19
+ * @param {number} opts.totalFindings - Total number of findings
20
+ * @param {number} opts.criticalFindings - Number of critical/high findings
21
+ * @param {number} opts.filesScanned - Number of files scanned
22
+ * @returns {string} Grade (A+ through F)
23
+ */
24
+ function calculateGrade({ totalFindings = 0, criticalFindings = 0, filesScanned = 1 }) {
25
+ if (totalFindings === 0) return 'A+';
26
+ const ratio = totalFindings / Math.max(filesScanned, 1);
27
+ if (criticalFindings > 0) {
28
+ return criticalFindings >= 3 ? 'F' : 'D';
29
+ }
30
+ if (ratio <= 0.05) return 'A';
31
+ if (ratio <= 0.15) return 'B';
32
+ if (ratio <= 0.3) return 'C';
33
+ if (ratio <= 0.5) return 'D';
34
+ return 'F';
35
+ }
36
+
37
+ /**
38
+ * Generate an SVG badge string.
39
+ * @param {string} grade - The security grade (A+, A, B, C, D, F)
40
+ * @returns {string} SVG markup
41
+ */
42
+ function generateBadgeSVG(grade) {
43
+ const g = GRADES[grade] || GRADES['F'];
44
+ const gradeText = grade.replace('+', '&#43;');
45
+ const labelWidth = 138;
46
+ const gradeWidth = 40;
47
+ const totalWidth = labelWidth + gradeWidth;
48
+
49
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20" role="img" aria-label="ClawMoat Security Score: ${grade}">
50
+ <title>ClawMoat Security Score: ${grade}</title>
51
+ <linearGradient id="s" x2="0" y2="100%">
52
+ <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
53
+ <stop offset="1" stop-opacity=".1"/>
54
+ </linearGradient>
55
+ <clipPath id="r">
56
+ <rect width="${totalWidth}" height="20" rx="3" fill="#fff"/>
57
+ </clipPath>
58
+ <g clip-path="url(#r)">
59
+ <rect width="${labelWidth}" height="20" fill="#0F172A"/>
60
+ <rect x="${labelWidth}" width="${gradeWidth}" height="20" fill="${g.color}"/>
61
+ <rect width="${totalWidth}" height="20" fill="url(#s)"/>
62
+ </g>
63
+ <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
64
+ <text aria-hidden="true" x="${labelWidth / 2}" y="15" fill="#010101" fill-opacity=".3">🏰 ClawMoat Score</text>
65
+ <text x="${labelWidth / 2}" y="14">🏰 ClawMoat Score</text>
66
+ <text aria-hidden="true" x="${labelWidth + gradeWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${gradeText}</text>
67
+ <text x="${labelWidth + gradeWidth / 2}" y="14" font-weight="bold">${gradeText}</text>
68
+ </g>
69
+ </svg>`;
70
+ }
71
+
72
+ /**
73
+ * Get a shields.io URL for the given grade.
74
+ * @param {string} grade
75
+ * @returns {string} shields.io badge URL
76
+ */
77
+ function getShieldsURL(grade) {
78
+ const colorMap = {
79
+ 'A+': 'brightgreen', 'A': 'green', 'B': 'yellowgreen',
80
+ 'C': 'yellow', 'D': 'orange', 'F': 'red',
81
+ };
82
+ const encoded = encodeURIComponent(grade);
83
+ const color = colorMap[grade] || 'red';
84
+ return `https://img.shields.io/badge/ClawMoat-${encoded}-${color}`;
85
+ }
86
+
87
+ module.exports = { calculateGrade, generateBadgeSVG, getShieldsURL, GRADES };
package/src/index.js ADDED
@@ -0,0 +1,316 @@
1
+ /**
2
+ * ClawMoat — Security moat for AI agents
3
+ *
4
+ * Runtime protection against prompt injection, jailbreaks, tool misuse,
5
+ * secret/PII leakage, data exfiltration, memory poisoning, and supply chain attacks.
6
+ *
7
+ * @module clawmoat
8
+ * @example
9
+ * const ClawMoat = require('clawmoat');
10
+ * const moat = new ClawMoat();
11
+ *
12
+ * // Scan inbound message for injection/jailbreak threats
13
+ * const result = moat.scanInbound(userMessage);
14
+ * if (!result.safe) console.log('Threat detected:', result.findings);
15
+ *
16
+ * // Scan outbound text for secret/PII leaks
17
+ * const out = moat.scanOutbound(responseText);
18
+ *
19
+ * // Evaluate a tool call against security policies
20
+ * const policy = moat.evaluateTool('exec', { command: 'rm -rf /' });
21
+ * if (policy.decision === 'deny') console.log('Blocked:', policy.reason);
22
+ */
23
+
24
+ const { scanPromptInjection } = require('./scanners/prompt-injection');
25
+ const { scanJailbreak } = require('./scanners/jailbreak');
26
+ const { scanSecrets } = require('./scanners/secrets');
27
+ const { scanPII } = require('./scanners/pii');
28
+ const { scanUrls } = require('./scanners/urls');
29
+ const { scanMemoryPoison } = require('./scanners/memory-poison');
30
+ const { scanExfiltration } = require('./scanners/exfiltration');
31
+ const { scanSkill, scanSkillContent } = require('./scanners/supply-chain');
32
+ const { evaluateToolCall } = require('./policies/engine');
33
+ const { SecurityLogger } = require('./utils/logger');
34
+ const { loadConfig } = require('./utils/config');
35
+
36
+ /**
37
+ * @typedef {Object} ScanFinding
38
+ * @property {string} type - Finding category (e.g. 'prompt_injection', 'secret_detected', 'pii_detected')
39
+ * @property {string} subtype - Specific detection pattern name
40
+ * @property {string} severity - 'low' | 'medium' | 'high' | 'critical'
41
+ * @property {string} [matched] - The matched text (may be redacted for secrets)
42
+ * @property {number} [position] - Character position in the scanned text
43
+ */
44
+
45
+ /**
46
+ * @typedef {Object} ScanResult
47
+ * @property {boolean} safe - true if no threats found
48
+ * @property {ScanFinding[]} findings - Array of detected issues
49
+ * @property {string|null} severity - Maximum severity across findings
50
+ * @property {string} action - 'allow' | 'log' | 'warn' | 'block'
51
+ */
52
+
53
+ /**
54
+ * @typedef {Object} ToolDecision
55
+ * @property {string} decision - 'allow' | 'deny' | 'warn' | 'review'
56
+ * @property {string} tool - Tool name evaluated
57
+ * @property {string} [reason] - Human-readable explanation
58
+ * @property {string} [severity] - Severity of the policy violation
59
+ */
60
+
61
+ /**
62
+ * Main ClawMoat security scanner class.
63
+ * Instantiate with optional config to scan text and evaluate tool calls.
64
+ */
65
+ class ClawMoat {
66
+ /**
67
+ * Create a ClawMoat instance.
68
+ * @param {Object} [opts] - Options
69
+ * @param {Object} [opts.config] - Configuration object (overrides file-based config)
70
+ * @param {string} [opts.configPath] - Path to clawmoat.yml config file
71
+ * @param {string} [opts.logFile] - Path to write security event logs
72
+ * @param {boolean} [opts.quiet] - Suppress console output
73
+ * @param {Function} [opts.onEvent] - Callback for each security event
74
+ */
75
+ constructor(opts = {}) {
76
+ this.config = opts.config || loadConfig(opts.configPath);
77
+ this.logger = new SecurityLogger({
78
+ logFile: opts.logFile,
79
+ quiet: opts.quiet,
80
+ minSeverity: this.config.alerts?.severity_threshold,
81
+ webhook: this.config.alerts?.webhook,
82
+ onEvent: opts.onEvent,
83
+ });
84
+ this.stats = { scanned: 0, blocked: 0, warnings: 0 };
85
+ }
86
+
87
+ /**
88
+ * Scan inbound text for prompt injection, jailbreaks, suspicious URLs, and memory poisoning.
89
+ * @param {string} text - Text to scan (message, email, web content, tool output)
90
+ * @param {Object} [opts] - Options
91
+ * @param {string} [opts.context] - Source context ('message' | 'email' | 'web' | 'tool_output')
92
+ * @returns {ScanResult} Scan result with findings and recommended action
93
+ */
94
+ scanInbound(text, opts = {}) {
95
+ this.stats.scanned++;
96
+ const results = { findings: [], safe: true, severity: null, action: 'allow' };
97
+
98
+ // Prompt injection scan
99
+ if (this.config.detection?.prompt_injection !== false) {
100
+ const pi = scanPromptInjection(text, opts);
101
+ if (!pi.clean) {
102
+ results.findings.push(...pi.findings);
103
+ results.safe = false;
104
+ }
105
+ }
106
+
107
+ // Jailbreak scan
108
+ if (this.config.detection?.jailbreak !== false) {
109
+ const jb = scanJailbreak(text);
110
+ if (!jb.clean) {
111
+ results.findings.push(...jb.findings);
112
+ results.safe = false;
113
+ }
114
+ }
115
+
116
+ // URL scan
117
+ if (this.config.detection?.url_scanning !== false) {
118
+ const urls = scanUrls(text, opts);
119
+ if (!urls.clean) {
120
+ results.findings.push(...urls.findings);
121
+ results.safe = false;
122
+ }
123
+ }
124
+
125
+ // Memory poisoning scan
126
+ if (this.config.detection?.memory_poison !== false) {
127
+ const mp = scanMemoryPoison(text, opts);
128
+ if (!mp.clean) {
129
+ results.findings.push(...mp.findings);
130
+ results.safe = false;
131
+ }
132
+ }
133
+
134
+ // Determine action
135
+ if (!results.safe) {
136
+ const maxSev = this._maxSeverity(results.findings);
137
+ results.severity = maxSev;
138
+ results.action = maxSev === 'critical' ? 'block' : maxSev === 'high' ? 'warn' : 'log';
139
+
140
+ if (results.action === 'block') this.stats.blocked++;
141
+ if (results.action === 'warn') this.stats.warnings++;
142
+
143
+ this.logger.log({
144
+ type: 'inbound_threat',
145
+ severity: maxSev,
146
+ message: `${results.findings.length} threat(s) detected in ${opts.context || 'message'}`,
147
+ details: {
148
+ findings: results.findings.map(f => ({ type: f.type, subtype: f.subtype, severity: f.severity })),
149
+ source: opts.context,
150
+ textPreview: text.substring(0, 100),
151
+ },
152
+ });
153
+ }
154
+
155
+ return results;
156
+ }
157
+
158
+ /**
159
+ * Scan outbound text for secrets, PII, and data exfiltration attempts.
160
+ * @param {string} text - Outbound text to scan
161
+ * @param {Object} [opts] - Options
162
+ * @param {string} [opts.context] - Source context for logging
163
+ * @returns {ScanResult} Scan result with findings and recommended action
164
+ */
165
+ scanOutbound(text, opts = {}) {
166
+ this.stats.scanned++;
167
+ const results = { findings: [], safe: true, severity: null, action: 'allow' };
168
+
169
+ // Secret scanning
170
+ if (this.config.detection?.secret_scanning !== false) {
171
+ const secrets = scanSecrets(text, { direction: 'outbound', ...opts });
172
+ if (!secrets.clean) {
173
+ results.findings.push(...secrets.findings);
174
+ results.safe = false;
175
+ }
176
+ }
177
+
178
+ // PII scanning
179
+ if (this.config.detection?.pii !== false) {
180
+ const pii = scanPII(text, opts);
181
+ if (!pii.clean) {
182
+ results.findings.push(...pii.findings);
183
+ results.safe = false;
184
+ }
185
+ }
186
+
187
+ // Exfiltration scanning
188
+ if (this.config.detection?.exfiltration !== false) {
189
+ const exfil = scanExfiltration(text, opts);
190
+ if (!exfil.clean) {
191
+ results.findings.push(...exfil.findings);
192
+ results.safe = false;
193
+ }
194
+ }
195
+
196
+ if (!results.safe) {
197
+ const maxSev = this._maxSeverity(results.findings);
198
+ results.severity = maxSev;
199
+ results.action = maxSev === 'critical' ? 'block' : 'warn';
200
+
201
+ this.stats.blocked++;
202
+ this.logger.log({
203
+ type: 'outbound_leak',
204
+ severity: maxSev,
205
+ message: `Secret/credential detected in outbound ${opts.context || 'message'}`,
206
+ details: {
207
+ findings: results.findings.map(f => ({ type: f.type, subtype: f.subtype, severity: f.severity, matched: f.matched })),
208
+ },
209
+ });
210
+ }
211
+
212
+ return results;
213
+ }
214
+
215
+ /**
216
+ * Evaluate a tool call against security policies.
217
+ * @param {string} tool - Tool name (e.g. 'exec', 'read', 'write', 'browser')
218
+ * @param {Object} args - Tool arguments to evaluate
219
+ * @returns {ToolDecision} Policy decision with explanation
220
+ */
221
+ evaluateTool(tool, args) {
222
+ const result = evaluateToolCall(tool, args, this.config.policies || {});
223
+
224
+ if (result.decision !== 'allow') {
225
+ const severity = result.severity || 'medium';
226
+ if (result.decision === 'deny') this.stats.blocked++;
227
+ if (result.decision === 'warn') this.stats.warnings++;
228
+
229
+ this.logger.log({
230
+ type: 'tool_policy',
231
+ severity,
232
+ message: `Tool ${tool}: ${result.decision} — ${result.reason}`,
233
+ details: { tool, decision: result.decision, ...result },
234
+ });
235
+ }
236
+
237
+ return result;
238
+ }
239
+
240
+ /**
241
+ * Full bidirectional scan: check text as both inbound threat AND outbound leak.
242
+ * @param {string} text - Text to scan
243
+ * @param {Object} [opts] - Options passed to both scanInbound and scanOutbound
244
+ * @returns {{ safe: boolean, inbound: ScanResult, outbound: ScanResult, findings: ScanFinding[] }}
245
+ */
246
+ scan(text, opts = {}) {
247
+ const inbound = this.scanInbound(text, opts);
248
+ const outbound = this.scanOutbound(text, opts);
249
+
250
+ return {
251
+ safe: inbound.safe && outbound.safe,
252
+ inbound,
253
+ outbound,
254
+ findings: [...inbound.findings, ...outbound.findings],
255
+ };
256
+ }
257
+
258
+ /**
259
+ * Get security event log
260
+ */
261
+ getEvents(filter) {
262
+ return this.logger.getEvents(filter);
263
+ }
264
+
265
+ /**
266
+ * Get summary stats
267
+ */
268
+ getSummary() {
269
+ return {
270
+ ...this.stats,
271
+ events: this.logger.summary(),
272
+ };
273
+ }
274
+
275
+ /**
276
+ * Scan a skill directory or file for supply chain threats (malicious code patterns).
277
+ * @param {string} skillPath - Path to skill directory or file
278
+ * @returns {{ clean: boolean, findings: ScanFinding[], severity: string|null }}
279
+ */
280
+ scanSkill(skillPath) {
281
+ const result = scanSkill(skillPath);
282
+
283
+ if (!result.clean) {
284
+ const maxSev = this._maxSeverity(result.findings);
285
+ this.logger.log({
286
+ type: 'supply_chain_threat',
287
+ severity: maxSev,
288
+ message: `${result.findings.length} issue(s) in skill: ${skillPath}`,
289
+ details: { findings: result.findings },
290
+ });
291
+ }
292
+
293
+ return result;
294
+ }
295
+
296
+ _maxSeverity(findings) {
297
+ const rank = { low: 0, medium: 1, high: 2, critical: 3 };
298
+ return findings.reduce(
299
+ (max, f) => (rank[f.severity] || 0) > (rank[max] || 0) ? f.severity : max,
300
+ 'low'
301
+ );
302
+ }
303
+ }
304
+
305
+ module.exports = ClawMoat;
306
+ module.exports.ClawMoat = ClawMoat;
307
+ module.exports.scanPromptInjection = scanPromptInjection;
308
+ module.exports.scanJailbreak = scanJailbreak;
309
+ module.exports.scanSecrets = scanSecrets;
310
+ module.exports.scanPII = scanPII;
311
+ module.exports.scanUrls = scanUrls;
312
+ module.exports.scanMemoryPoison = scanMemoryPoison;
313
+ module.exports.scanExfiltration = scanExfiltration;
314
+ module.exports.scanSkill = scanSkill;
315
+ module.exports.scanSkillContent = scanSkillContent;
316
+ module.exports.evaluateToolCall = evaluateToolCall;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * ClawMoat — OpenClaw Integration Middleware
3
+ *
4
+ * Hooks into OpenClaw's session transcript files to provide
5
+ * real-time monitoring and alerting.
6
+ *
7
+ * Usage:
8
+ * const { watchSessions } = require('clawmoat/src/middleware/openclaw');
9
+ * watchSessions({ agentDir: '~/.openclaw/agents/main' });
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const ClawMoat = require('../index');
15
+
16
+ /**
17
+ * Watch OpenClaw session files for security events
18
+ */
19
+ function watchSessions(opts = {}) {
20
+ const agentDir = expandHome(opts.agentDir || '~/.openclaw/agents/main');
21
+ const sessionsDir = path.join(agentDir, 'sessions');
22
+ const moat = new ClawMoat(opts);
23
+
24
+ if (!fs.existsSync(sessionsDir)) {
25
+ console.error(`[ClawMoat] Sessions directory not found: ${sessionsDir}`);
26
+ return null;
27
+ }
28
+
29
+ console.log(`[ClawMoat] 🏰 Watching sessions in ${sessionsDir}`);
30
+
31
+ // Track file sizes to only read new content
32
+ const filePositions = {};
33
+
34
+ const watcher = fs.watch(sessionsDir, (eventType, filename) => {
35
+ if (!filename || !filename.endsWith('.jsonl')) return;
36
+
37
+ const filePath = path.join(sessionsDir, filename);
38
+
39
+ try {
40
+ const stat = fs.statSync(filePath);
41
+ const lastPos = filePositions[filename] || 0;
42
+
43
+ if (stat.size <= lastPos) return;
44
+
45
+ // Read only new content
46
+ const fd = fs.openSync(filePath, 'r');
47
+ const buffer = Buffer.alloc(stat.size - lastPos);
48
+ fs.readSync(fd, buffer, 0, buffer.length, lastPos);
49
+ fs.closeSync(fd);
50
+
51
+ filePositions[filename] = stat.size;
52
+
53
+ const newContent = buffer.toString('utf8');
54
+ const lines = newContent.split('\n').filter(Boolean);
55
+
56
+ for (const line of lines) {
57
+ try {
58
+ const entry = JSON.parse(line);
59
+ processEntry(moat, entry, filename);
60
+ } catch {}
61
+ }
62
+ } catch {}
63
+ });
64
+
65
+ return {
66
+ moat,
67
+ watcher,
68
+ stop: () => watcher.close(),
69
+ getEvents: (filter) => moat.getEvents(filter),
70
+ getSummary: () => moat.getSummary(),
71
+ };
72
+ }
73
+
74
+ function processEntry(moat, entry, sessionFile) {
75
+ // Scan user messages (inbound)
76
+ if (entry.role === 'user') {
77
+ const text = extractText(entry);
78
+ if (text) {
79
+ const result = moat.scanInbound(text, { context: 'message', session: sessionFile });
80
+ if (!result.safe && result.action === 'block') {
81
+ console.error(`[ClawMoat] 🚨 BLOCKED threat in ${sessionFile}: ${result.findings[0]?.subtype}`);
82
+ }
83
+ }
84
+ }
85
+
86
+ // Audit tool calls (from assistant)
87
+ if (entry.role === 'assistant' && Array.isArray(entry.content)) {
88
+ for (const part of entry.content) {
89
+ if (part.type === 'toolCall') {
90
+ const result = moat.evaluateTool(part.name, part.arguments || {});
91
+ if (result.decision === 'deny') {
92
+ console.error(`[ClawMoat] 🚨 BLOCKED tool call: ${part.name} — ${result.reason}`);
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ // Scan tool results for injected content
99
+ if (entry.role === 'tool') {
100
+ const text = extractText(entry);
101
+ if (text) {
102
+ moat.scanInbound(text, { context: 'tool_output', session: sessionFile });
103
+ }
104
+ }
105
+
106
+ // Check outbound messages for secrets
107
+ if (entry.role === 'assistant') {
108
+ const text = extractText(entry);
109
+ if (text) {
110
+ const result = moat.scanOutbound(text, { context: 'assistant_reply', session: sessionFile });
111
+ if (!result.safe) {
112
+ console.error(`[ClawMoat] 🚨 SECRET in outbound message: ${result.findings[0]?.subtype}`);
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ function extractText(entry) {
119
+ if (typeof entry.content === 'string') return entry.content;
120
+ if (Array.isArray(entry.content)) {
121
+ return entry.content
122
+ .filter(c => c.type === 'text')
123
+ .map(c => c.text)
124
+ .join('\n');
125
+ }
126
+ return null;
127
+ }
128
+
129
+ function expandHome(p) {
130
+ return p.replace(/^~/, process.env.HOME || '/home/user');
131
+ }
132
+
133
+ module.exports = { watchSessions };