shellward 0.5.10 → 0.5.11
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/README.md +99 -14
- package/dist/audit-log.d.ts +8 -0
- package/dist/audit-log.js +72 -0
- package/dist/auto-check.d.ts +26 -0
- package/dist/auto-check.js +167 -0
- package/dist/commands/audit.d.ts +2 -0
- package/dist/commands/audit.js +75 -0
- package/dist/commands/check-updates.d.ts +2 -0
- package/dist/commands/check-updates.js +166 -0
- package/dist/commands/harden.d.ts +2 -0
- package/dist/commands/harden.js +218 -0
- package/dist/commands/index.d.ts +2 -0
- package/dist/commands/index.js +56 -0
- package/dist/commands/scan-plugins.d.ts +2 -0
- package/dist/commands/scan-plugins.js +186 -0
- package/dist/commands/security.d.ts +2 -0
- package/dist/commands/security.js +109 -0
- package/dist/commands/upgrade-openclaw.d.ts +2 -0
- package/dist/commands/upgrade-openclaw.js +54 -0
- package/dist/core/engine.d.ts +66 -0
- package/dist/core/engine.js +572 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +137 -0
- package/dist/layers/data-flow-guard.d.ts +2 -0
- package/dist/layers/data-flow-guard.js +23 -0
- package/dist/layers/input-auditor.d.ts +2 -0
- package/dist/layers/input-auditor.js +33 -0
- package/dist/layers/outbound-guard.d.ts +2 -0
- package/dist/layers/outbound-guard.js +22 -0
- package/dist/layers/output-scanner.d.ts +2 -0
- package/dist/layers/output-scanner.js +16 -0
- package/dist/layers/prompt-guard.d.ts +2 -0
- package/dist/layers/prompt-guard.js +14 -0
- package/dist/layers/security-gate.d.ts +2 -0
- package/dist/layers/security-gate.js +49 -0
- package/dist/layers/session-guard.d.ts +2 -0
- package/dist/layers/session-guard.js +34 -0
- package/dist/layers/tool-blocker.d.ts +2 -0
- package/dist/layers/tool-blocker.js +28 -0
- package/dist/mcp-server.d.ts +2 -0
- package/dist/mcp-server.js +337 -0
- package/dist/rules/dangerous-commands.d.ts +8 -0
- package/dist/rules/dangerous-commands.js +113 -0
- package/dist/rules/injection-en.d.ts +2 -0
- package/dist/rules/injection-en.js +115 -0
- package/dist/rules/injection-zh.d.ts +2 -0
- package/dist/rules/injection-zh.js +132 -0
- package/dist/rules/protected-paths.d.ts +2 -0
- package/dist/rules/protected-paths.js +75 -0
- package/dist/rules/sensitive-patterns.d.ts +21 -0
- package/dist/rules/sensitive-patterns.js +192 -0
- package/dist/types.d.ts +64 -0
- package/dist/types.js +30 -0
- package/dist/update-check.d.ts +40 -0
- package/dist/update-check.js +147 -0
- package/dist/utils.d.ts +4 -0
- package/dist/utils.js +8 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +18 -6
- package/src/audit-log.ts +8 -4
- package/src/auto-check.ts +2 -2
- package/src/commands/audit.ts +3 -3
- package/src/commands/check-updates.ts +4 -4
- package/src/commands/harden.ts +3 -3
- package/src/commands/index.ts +8 -8
- package/src/commands/scan-plugins.ts +3 -3
- package/src/commands/security.ts +3 -3
- package/src/commands/upgrade-openclaw.ts +2 -2
- package/src/core/engine.ts +8 -8
- package/src/index.ts +15 -15
- package/src/layers/data-flow-guard.ts +1 -1
- package/src/layers/input-auditor.ts +1 -1
- package/src/layers/outbound-guard.ts +1 -1
- package/src/layers/output-scanner.ts +1 -1
- package/src/layers/prompt-guard.ts +1 -1
- package/src/layers/security-gate.ts +1 -1
- package/src/layers/session-guard.ts +1 -1
- package/src/layers/tool-blocker.ts +1 -1
- package/src/mcp-server.ts +386 -0
- package/src/rules/dangerous-commands.ts +1 -1
- package/src/rules/injection-en.ts +1 -1
- package/src/rules/injection-zh.ts +1 -1
- package/src/rules/protected-paths.ts +1 -1
- package/src/rules/sensitive-patterns.ts +1 -1
- package/src/update-check.ts +1 -1
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
// src/mcp-server.ts — ShellWard MCP Server
|
|
3
|
+
//
|
|
4
|
+
// Exposes ShellWard's 8-layer security engine as an MCP server.
|
|
5
|
+
// Zero dependencies — implements MCP protocol over stdio natively.
|
|
6
|
+
//
|
|
7
|
+
// Usage:
|
|
8
|
+
// npx tsx src/mcp-server.ts
|
|
9
|
+
//
|
|
10
|
+
// MCP config (claude_desktop_config.json / openclaw settings):
|
|
11
|
+
// {
|
|
12
|
+
// "mcpServers": {
|
|
13
|
+
// "shellward": {
|
|
14
|
+
// "command": "npx",
|
|
15
|
+
// "args": ["tsx", "/path/to/shellward/src/mcp-server.ts"]
|
|
16
|
+
// }
|
|
17
|
+
// }
|
|
18
|
+
// }
|
|
19
|
+
|
|
20
|
+
import { ShellWard } from './core/engine.js'
|
|
21
|
+
import { readFileSync } from 'fs'
|
|
22
|
+
import { fileURLToPath } from 'url'
|
|
23
|
+
import { dirname, join } from 'path'
|
|
24
|
+
|
|
25
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
26
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'))
|
|
27
|
+
|
|
28
|
+
// ===== MCP Protocol Types =====
|
|
29
|
+
|
|
30
|
+
interface JsonRpcRequest {
|
|
31
|
+
jsonrpc: '2.0'
|
|
32
|
+
id?: number | string
|
|
33
|
+
method: string
|
|
34
|
+
params?: Record<string, unknown>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface JsonRpcResponse {
|
|
38
|
+
jsonrpc: '2.0'
|
|
39
|
+
id: number | string | null
|
|
40
|
+
result?: unknown
|
|
41
|
+
error?: { code: number; message: string; data?: unknown }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ===== ShellWard Instance =====
|
|
45
|
+
|
|
46
|
+
const guard = new ShellWard({
|
|
47
|
+
mode: (process.env.SHELLWARD_MODE as 'enforce' | 'audit') || 'enforce',
|
|
48
|
+
locale: (process.env.SHELLWARD_LOCALE as 'auto' | 'zh' | 'en') || 'auto',
|
|
49
|
+
autoCheckOnStartup: false,
|
|
50
|
+
layers: {
|
|
51
|
+
promptGuard: true,
|
|
52
|
+
outputScanner: true,
|
|
53
|
+
toolBlocker: true,
|
|
54
|
+
inputAuditor: true,
|
|
55
|
+
securityGate: true,
|
|
56
|
+
outboundGuard: true,
|
|
57
|
+
dataFlowGuard: true,
|
|
58
|
+
sessionGuard: true,
|
|
59
|
+
},
|
|
60
|
+
injectionThreshold: Number(process.env.SHELLWARD_THRESHOLD) || 60,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// ===== Tool Definitions =====
|
|
64
|
+
|
|
65
|
+
const TOOLS = [
|
|
66
|
+
{
|
|
67
|
+
name: 'check_command',
|
|
68
|
+
description: 'Check if a shell command is safe to execute. Detects rm -rf, reverse shells, fork bombs, curl|sh, etc.',
|
|
69
|
+
inputSchema: {
|
|
70
|
+
type: 'object' as const,
|
|
71
|
+
properties: {
|
|
72
|
+
command: { type: 'string', description: 'The shell command to check' },
|
|
73
|
+
},
|
|
74
|
+
required: ['command'],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: 'check_injection',
|
|
79
|
+
description: 'Detect prompt injection attempts in text. Supports 32+ rules for Chinese and English, with hidden character detection.',
|
|
80
|
+
inputSchema: {
|
|
81
|
+
type: 'object' as const,
|
|
82
|
+
properties: {
|
|
83
|
+
text: { type: 'string', description: 'Text to scan for injection attempts' },
|
|
84
|
+
threshold: { type: 'number', description: 'Detection threshold 0-100 (default: 60, lower = stricter)' },
|
|
85
|
+
},
|
|
86
|
+
required: ['text'],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: 'scan_data',
|
|
91
|
+
description: 'Scan text for sensitive data: PII (Chinese ID cards, phone numbers, bank cards), API keys, passwords, private keys, JWT tokens, SSN, credit cards.',
|
|
92
|
+
inputSchema: {
|
|
93
|
+
type: 'object' as const,
|
|
94
|
+
properties: {
|
|
95
|
+
text: { type: 'string', description: 'Text to scan for sensitive data' },
|
|
96
|
+
},
|
|
97
|
+
required: ['text'],
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: 'check_path',
|
|
102
|
+
description: 'Check if a file path operation is safe. Protects .env, .ssh/, .aws/credentials, private keys, /etc/passwd, etc.',
|
|
103
|
+
inputSchema: {
|
|
104
|
+
type: 'object' as const,
|
|
105
|
+
properties: {
|
|
106
|
+
path: { type: 'string', description: 'File path to check' },
|
|
107
|
+
operation: { type: 'string', enum: ['write', 'delete'], description: 'Operation type' },
|
|
108
|
+
},
|
|
109
|
+
required: ['path', 'operation'],
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: 'check_tool',
|
|
114
|
+
description: 'Check if a tool name is allowed. Blocks payment/transfer tools, flags exec/shell tools as sensitive.',
|
|
115
|
+
inputSchema: {
|
|
116
|
+
type: 'object' as const,
|
|
117
|
+
properties: {
|
|
118
|
+
tool_name: { type: 'string', description: 'Tool name to check (e.g. "bash", "stripe_charge", "file_read")' },
|
|
119
|
+
},
|
|
120
|
+
required: ['tool_name'],
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: 'check_response',
|
|
125
|
+
description: 'Check an AI response for security issues: canary token leaks and sensitive data exposure.',
|
|
126
|
+
inputSchema: {
|
|
127
|
+
type: 'object' as const,
|
|
128
|
+
properties: {
|
|
129
|
+
content: { type: 'string', description: 'Response content to check' },
|
|
130
|
+
},
|
|
131
|
+
required: ['content'],
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: 'security_status',
|
|
136
|
+
description: 'Get current ShellWard security status: mode, active layers, detection capabilities.',
|
|
137
|
+
inputSchema: {
|
|
138
|
+
type: 'object' as const,
|
|
139
|
+
properties: {},
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
// ===== Tool Execution =====
|
|
145
|
+
|
|
146
|
+
function executeTool(name: string, args: Record<string, unknown>): unknown {
|
|
147
|
+
switch (name) {
|
|
148
|
+
case 'check_command': {
|
|
149
|
+
const result = guard.checkCommand(String(args.command || ''))
|
|
150
|
+
return {
|
|
151
|
+
safe: result.allowed,
|
|
152
|
+
level: result.level || null,
|
|
153
|
+
reason: result.reason || null,
|
|
154
|
+
rule_id: result.ruleId || null,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
case 'check_injection': {
|
|
159
|
+
const opts = typeof args.threshold === 'number' ? { threshold: args.threshold } : undefined
|
|
160
|
+
const result = guard.checkInjection(String(args.text || ''), opts)
|
|
161
|
+
return {
|
|
162
|
+
safe: result.safe,
|
|
163
|
+
score: result.score,
|
|
164
|
+
threshold: result.threshold,
|
|
165
|
+
matched_rules: result.matched.map((m: any) => ({
|
|
166
|
+
id: m.id,
|
|
167
|
+
name: m.name,
|
|
168
|
+
score: m.score,
|
|
169
|
+
})),
|
|
170
|
+
hidden_chars: result.hiddenChars,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
case 'scan_data': {
|
|
175
|
+
const result = guard.scanData(String(args.text || ''))
|
|
176
|
+
return {
|
|
177
|
+
has_sensitive_data: result.hasSensitiveData,
|
|
178
|
+
findings: result.findings.map((f: any) => ({
|
|
179
|
+
type: f.id,
|
|
180
|
+
name: f.name,
|
|
181
|
+
count: f.count,
|
|
182
|
+
})),
|
|
183
|
+
summary: result.summary,
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
case 'check_path': {
|
|
188
|
+
const op = String(args.operation || '')
|
|
189
|
+
if (op !== 'write' && op !== 'delete') {
|
|
190
|
+
throw new Error(`Invalid operation: "${op}". Must be "write" or "delete".`)
|
|
191
|
+
}
|
|
192
|
+
const result = guard.checkPath(String(args.path || ''), op)
|
|
193
|
+
return {
|
|
194
|
+
safe: result.allowed,
|
|
195
|
+
level: result.level || null,
|
|
196
|
+
reason: result.reason || null,
|
|
197
|
+
rule_id: result.ruleId || null,
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
case 'check_tool': {
|
|
202
|
+
const result = guard.checkTool(String(args.tool_name || ''))
|
|
203
|
+
return {
|
|
204
|
+
allowed: result.allowed,
|
|
205
|
+
level: result.level || null,
|
|
206
|
+
reason: result.reason || null,
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
case 'check_response': {
|
|
211
|
+
const result = guard.checkResponse(String(args.content || ''))
|
|
212
|
+
return {
|
|
213
|
+
canary_leak: result.canaryLeak,
|
|
214
|
+
has_sensitive_data: result.sensitiveData.hasSensitiveData,
|
|
215
|
+
findings: result.sensitiveData.findings.map(f => ({
|
|
216
|
+
type: f.id,
|
|
217
|
+
name: f.name,
|
|
218
|
+
count: f.count,
|
|
219
|
+
})),
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
case 'security_status': {
|
|
224
|
+
return {
|
|
225
|
+
mode: guard.config.mode,
|
|
226
|
+
locale: guard.locale,
|
|
227
|
+
injection_threshold: guard.config.injectionThreshold,
|
|
228
|
+
layers: guard.config.layers,
|
|
229
|
+
capabilities: [
|
|
230
|
+
'command_safety_check (17 dangerous patterns)',
|
|
231
|
+
'prompt_injection_detection (32+ rules, zh+en)',
|
|
232
|
+
'pii_detection (CN ID/phone/bank + global)',
|
|
233
|
+
'path_protection (12 protected patterns)',
|
|
234
|
+
'tool_policy (block payment/transfer)',
|
|
235
|
+
'response_audit (canary + PII)',
|
|
236
|
+
'data_flow_tracking (DLP)',
|
|
237
|
+
],
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
default:
|
|
242
|
+
throw new Error(`Unknown tool: ${name}`)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ===== MCP Protocol Handlers =====
|
|
247
|
+
|
|
248
|
+
function handleRequest(req: JsonRpcRequest): JsonRpcResponse | null {
|
|
249
|
+
const { id, method, params } = req
|
|
250
|
+
|
|
251
|
+
switch (method) {
|
|
252
|
+
case 'initialize':
|
|
253
|
+
return {
|
|
254
|
+
jsonrpc: '2.0',
|
|
255
|
+
id: id ?? null,
|
|
256
|
+
result: {
|
|
257
|
+
protocolVersion: '2024-11-05',
|
|
258
|
+
capabilities: { tools: {} },
|
|
259
|
+
serverInfo: {
|
|
260
|
+
name: 'shellward',
|
|
261
|
+
version: pkg.version,
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
case 'notifications/initialized':
|
|
267
|
+
// Client acknowledgement, no response needed
|
|
268
|
+
return null
|
|
269
|
+
|
|
270
|
+
case 'tools/list':
|
|
271
|
+
return {
|
|
272
|
+
jsonrpc: '2.0',
|
|
273
|
+
id: id ?? null,
|
|
274
|
+
result: { tools: TOOLS },
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
case 'resources/list':
|
|
278
|
+
return { jsonrpc: '2.0', id: id ?? null, result: { resources: [] } }
|
|
279
|
+
|
|
280
|
+
case 'prompts/list':
|
|
281
|
+
return { jsonrpc: '2.0', id: id ?? null, result: { prompts: [] } }
|
|
282
|
+
|
|
283
|
+
case 'tools/call': {
|
|
284
|
+
const toolName = (params as any)?.name as string
|
|
285
|
+
const toolArgs = ((params as any)?.arguments || {}) as Record<string, unknown>
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
const result = executeTool(toolName, toolArgs)
|
|
289
|
+
return {
|
|
290
|
+
jsonrpc: '2.0',
|
|
291
|
+
id: id ?? null,
|
|
292
|
+
result: {
|
|
293
|
+
content: [
|
|
294
|
+
{
|
|
295
|
+
type: 'text',
|
|
296
|
+
text: JSON.stringify(result, null, 2),
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
},
|
|
300
|
+
}
|
|
301
|
+
} catch (err: any) {
|
|
302
|
+
return {
|
|
303
|
+
jsonrpc: '2.0',
|
|
304
|
+
id: id ?? null,
|
|
305
|
+
result: {
|
|
306
|
+
content: [
|
|
307
|
+
{
|
|
308
|
+
type: 'text',
|
|
309
|
+
text: JSON.stringify({ error: err.message }),
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
isError: true,
|
|
313
|
+
},
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
case 'ping':
|
|
319
|
+
return { jsonrpc: '2.0', id: id ?? null, result: {} }
|
|
320
|
+
|
|
321
|
+
default:
|
|
322
|
+
// Unknown methods — return error for requests with id, ignore notifications
|
|
323
|
+
if (id !== undefined) {
|
|
324
|
+
return {
|
|
325
|
+
jsonrpc: '2.0',
|
|
326
|
+
id: id ?? null,
|
|
327
|
+
error: { code: -32601, message: `Method not found: ${method}` },
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return null
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ===== Stdio Transport =====
|
|
335
|
+
// Use raw Buffer to handle UTF-8 multi-byte characters correctly.
|
|
336
|
+
// Content-Length is in bytes, not characters.
|
|
337
|
+
|
|
338
|
+
let rawBuffer = Buffer.alloc(0)
|
|
339
|
+
|
|
340
|
+
process.stdin.on('data', (chunk: Buffer) => {
|
|
341
|
+
rawBuffer = Buffer.concat([rawBuffer, chunk])
|
|
342
|
+
|
|
343
|
+
while (true) {
|
|
344
|
+
const headerEnd = rawBuffer.indexOf('\r\n\r\n')
|
|
345
|
+
if (headerEnd === -1) break
|
|
346
|
+
|
|
347
|
+
const header = rawBuffer.slice(0, headerEnd).toString('ascii')
|
|
348
|
+
const lengthMatch = header.match(/Content-Length:\s*(\d+)/i)
|
|
349
|
+
if (!lengthMatch) {
|
|
350
|
+
rawBuffer = rawBuffer.slice(headerEnd + 4)
|
|
351
|
+
continue
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const contentLength = parseInt(lengthMatch[1], 10)
|
|
355
|
+
const bodyStart = headerEnd + 4
|
|
356
|
+
if (rawBuffer.length < bodyStart + contentLength) break
|
|
357
|
+
|
|
358
|
+
const body = rawBuffer.slice(bodyStart, bodyStart + contentLength).toString('utf8')
|
|
359
|
+
rawBuffer = rawBuffer.slice(bodyStart + contentLength)
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
const req = JSON.parse(body) as JsonRpcRequest
|
|
363
|
+
const res = handleRequest(req)
|
|
364
|
+
if (res) {
|
|
365
|
+
send(res)
|
|
366
|
+
}
|
|
367
|
+
} catch {
|
|
368
|
+
send({
|
|
369
|
+
jsonrpc: '2.0',
|
|
370
|
+
id: null,
|
|
371
|
+
error: { code: -32700, message: 'Parse error' },
|
|
372
|
+
})
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
function send(msg: JsonRpcResponse) {
|
|
378
|
+
const body = JSON.stringify(msg)
|
|
379
|
+
const header = `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n`
|
|
380
|
+
process.stdout.write(header + body)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
process.stdin.on('end', () => process.exit(0))
|
|
384
|
+
|
|
385
|
+
// Log to stderr so it doesn't interfere with stdio protocol
|
|
386
|
+
process.stderr.write(`[ShellWard MCP] Server started (mode: ${guard.config.mode}, locale: ${guard.locale})\n`)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/rules/sensitive-patterns.ts — PII & secret patterns for output redaction (global + China)
|
|
2
2
|
|
|
3
|
-
import type { NamedPattern, ScanMatch } from '../types'
|
|
3
|
+
import type { NamedPattern, ScanMatch } from '../types.js'
|
|
4
4
|
|
|
5
5
|
export interface SensitivePattern {
|
|
6
6
|
id: string
|
package/src/update-check.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { get } from 'https'
|
|
11
11
|
import { mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
12
12
|
import { join } from 'path'
|
|
13
|
-
import { getHomeDir } from './utils'
|
|
13
|
+
import { getHomeDir } from './utils.js'
|
|
14
14
|
|
|
15
15
|
const CACHE_DIR = join(getHomeDir(), '.openclaw', 'shellward')
|
|
16
16
|
const CACHE_FILE = join(CACHE_DIR, 'update-cache.json')
|