@ellistevo/openclaw-secure 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.
- package/LICENSE +21 -0
- package/MOLTBOOK-POST.md +66 -0
- package/OPENCLAW-PR.md +136 -0
- package/README.md +222 -0
- package/bin/cli.js +399 -0
- package/package.json +43 -0
- package/src/index.js +57 -0
- package/src/schema.js +269 -0
- package/src/signing.js +193 -0
- package/src/trust.js +279 -0
- package/src/validator.js +128 -0
- package/test/all.test.js +529 -0
package/src/schema.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClaw Secure Manifest Schema
|
|
3
|
+
* JSON Schema for validating skill.yaml manifests
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const manifestSchema = {
|
|
7
|
+
$schema: 'http://json-schema.org/draft-07/schema#',
|
|
8
|
+
$id: 'https://openclaw-secure.sociable.social/manifest.schema.json',
|
|
9
|
+
title: 'OpenClaw Skill Manifest',
|
|
10
|
+
description: 'Security manifest for OpenClaw skills',
|
|
11
|
+
type: 'object',
|
|
12
|
+
required: ['name', 'version', 'author', 'permissions'],
|
|
13
|
+
properties: {
|
|
14
|
+
// === METADATA ===
|
|
15
|
+
name: {
|
|
16
|
+
type: 'string',
|
|
17
|
+
pattern: '^[a-z][a-z0-9-]*$',
|
|
18
|
+
minLength: 2,
|
|
19
|
+
maxLength: 64,
|
|
20
|
+
description: 'Unique skill identifier (lowercase, hyphens allowed)'
|
|
21
|
+
},
|
|
22
|
+
version: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
pattern: '^\\d+\\.\\d+\\.\\d+',
|
|
25
|
+
description: 'Semantic version (e.g., 1.0.0)'
|
|
26
|
+
},
|
|
27
|
+
display_name: {
|
|
28
|
+
type: 'string',
|
|
29
|
+
maxLength: 128,
|
|
30
|
+
description: 'Human-readable name'
|
|
31
|
+
},
|
|
32
|
+
description: {
|
|
33
|
+
type: 'string',
|
|
34
|
+
maxLength: 500,
|
|
35
|
+
description: 'Brief description of what the skill does'
|
|
36
|
+
},
|
|
37
|
+
author: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
required: ['name'],
|
|
40
|
+
properties: {
|
|
41
|
+
name: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
minLength: 1,
|
|
44
|
+
maxLength: 64
|
|
45
|
+
},
|
|
46
|
+
moltbook: {
|
|
47
|
+
type: 'string',
|
|
48
|
+
description: 'Moltbook username for identity verification'
|
|
49
|
+
},
|
|
50
|
+
email: {
|
|
51
|
+
type: 'string',
|
|
52
|
+
format: 'email'
|
|
53
|
+
},
|
|
54
|
+
url: {
|
|
55
|
+
type: 'string',
|
|
56
|
+
format: 'uri'
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
license: {
|
|
61
|
+
type: 'string',
|
|
62
|
+
description: 'SPDX license identifier'
|
|
63
|
+
},
|
|
64
|
+
repository: {
|
|
65
|
+
type: 'string',
|
|
66
|
+
format: 'uri'
|
|
67
|
+
},
|
|
68
|
+
homepage: {
|
|
69
|
+
type: 'string',
|
|
70
|
+
format: 'uri'
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
// === PERMISSIONS ===
|
|
74
|
+
permissions: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
required: [],
|
|
77
|
+
properties: {
|
|
78
|
+
network: {
|
|
79
|
+
type: 'object',
|
|
80
|
+
properties: {
|
|
81
|
+
allow: {
|
|
82
|
+
type: 'array',
|
|
83
|
+
items: { type: 'string' },
|
|
84
|
+
default: [],
|
|
85
|
+
description: 'Allowed domains (supports wildcards like *.example.com)'
|
|
86
|
+
},
|
|
87
|
+
deny: {
|
|
88
|
+
type: 'array',
|
|
89
|
+
items: { type: 'string' },
|
|
90
|
+
default: [],
|
|
91
|
+
description: 'Explicitly denied domains'
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
default: { allow: [], deny: [] }
|
|
95
|
+
},
|
|
96
|
+
filesystem: {
|
|
97
|
+
type: 'object',
|
|
98
|
+
properties: {
|
|
99
|
+
read: {
|
|
100
|
+
type: 'array',
|
|
101
|
+
items: { type: 'string' },
|
|
102
|
+
default: [],
|
|
103
|
+
description: 'Paths the skill can read'
|
|
104
|
+
},
|
|
105
|
+
write: {
|
|
106
|
+
type: 'array',
|
|
107
|
+
items: { type: 'string' },
|
|
108
|
+
default: [],
|
|
109
|
+
description: 'Paths the skill can write'
|
|
110
|
+
},
|
|
111
|
+
deny: {
|
|
112
|
+
type: 'array',
|
|
113
|
+
items: { type: 'string' },
|
|
114
|
+
default: ['~/.ssh', '~/.gnupg', '~/.config/openclaw'],
|
|
115
|
+
description: 'Paths that are always denied'
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
default: { read: [], write: [], deny: [] }
|
|
119
|
+
},
|
|
120
|
+
shell: {
|
|
121
|
+
type: 'object',
|
|
122
|
+
properties: {
|
|
123
|
+
allowed: {
|
|
124
|
+
type: 'boolean',
|
|
125
|
+
default: false,
|
|
126
|
+
description: 'Whether shell commands are allowed'
|
|
127
|
+
},
|
|
128
|
+
commands: {
|
|
129
|
+
type: 'array',
|
|
130
|
+
items: { type: 'string' },
|
|
131
|
+
default: [],
|
|
132
|
+
description: 'Allowlist of specific commands (if shell.allowed is true)'
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
default: { allowed: false, commands: [] }
|
|
136
|
+
},
|
|
137
|
+
credentials: {
|
|
138
|
+
type: 'array',
|
|
139
|
+
items: { type: 'string' },
|
|
140
|
+
default: [],
|
|
141
|
+
description: 'Environment variables this skill needs access to'
|
|
142
|
+
},
|
|
143
|
+
capabilities: {
|
|
144
|
+
type: 'object',
|
|
145
|
+
properties: {
|
|
146
|
+
browser: {
|
|
147
|
+
type: 'boolean',
|
|
148
|
+
default: false,
|
|
149
|
+
description: 'Can use browser automation'
|
|
150
|
+
},
|
|
151
|
+
messaging: {
|
|
152
|
+
type: 'boolean',
|
|
153
|
+
default: false,
|
|
154
|
+
description: 'Can send messages on behalf of user'
|
|
155
|
+
},
|
|
156
|
+
cron: {
|
|
157
|
+
type: 'boolean',
|
|
158
|
+
default: false,
|
|
159
|
+
description: 'Can schedule tasks'
|
|
160
|
+
},
|
|
161
|
+
spawn_agents: {
|
|
162
|
+
type: 'boolean',
|
|
163
|
+
default: false,
|
|
164
|
+
description: 'Can create sub-agents'
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
default: {
|
|
168
|
+
browser: false,
|
|
169
|
+
messaging: false,
|
|
170
|
+
cron: false,
|
|
171
|
+
spawn_agents: false
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
// === RESOURCE LIMITS ===
|
|
178
|
+
resources: {
|
|
179
|
+
type: 'object',
|
|
180
|
+
properties: {
|
|
181
|
+
max_memory_mb: {
|
|
182
|
+
type: 'integer',
|
|
183
|
+
minimum: 16,
|
|
184
|
+
maximum: 4096,
|
|
185
|
+
default: 256
|
|
186
|
+
},
|
|
187
|
+
max_cpu_percent: {
|
|
188
|
+
type: 'integer',
|
|
189
|
+
minimum: 1,
|
|
190
|
+
maximum: 100,
|
|
191
|
+
default: 25
|
|
192
|
+
},
|
|
193
|
+
max_runtime_seconds: {
|
|
194
|
+
type: 'integer',
|
|
195
|
+
minimum: 1,
|
|
196
|
+
maximum: 3600,
|
|
197
|
+
default: 60
|
|
198
|
+
},
|
|
199
|
+
max_network_mb: {
|
|
200
|
+
type: 'integer',
|
|
201
|
+
minimum: 0,
|
|
202
|
+
maximum: 1024,
|
|
203
|
+
default: 10
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
// === SIGNATURE ===
|
|
209
|
+
signature: {
|
|
210
|
+
type: 'object',
|
|
211
|
+
properties: {
|
|
212
|
+
algorithm: {
|
|
213
|
+
type: 'string',
|
|
214
|
+
enum: ['ed25519'],
|
|
215
|
+
default: 'ed25519'
|
|
216
|
+
},
|
|
217
|
+
signer: {
|
|
218
|
+
type: 'string',
|
|
219
|
+
description: 'Identity of the signer'
|
|
220
|
+
},
|
|
221
|
+
signed_at: {
|
|
222
|
+
type: 'string',
|
|
223
|
+
format: 'date-time'
|
|
224
|
+
},
|
|
225
|
+
public_key: {
|
|
226
|
+
type: 'string',
|
|
227
|
+
description: 'Base64-encoded public key'
|
|
228
|
+
},
|
|
229
|
+
signature: {
|
|
230
|
+
type: 'string',
|
|
231
|
+
description: 'Base64-encoded signature'
|
|
232
|
+
},
|
|
233
|
+
content_hash: {
|
|
234
|
+
type: 'string',
|
|
235
|
+
pattern: '^sha256:[a-f0-9]{64}$',
|
|
236
|
+
description: 'SHA256 hash of manifest content (excluding signature block)'
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// Default permissions for minimal-risk skills
|
|
244
|
+
const defaultPermissions = {
|
|
245
|
+
network: { allow: [], deny: [] },
|
|
246
|
+
filesystem: { read: [], write: [], deny: ['~/.ssh', '~/.gnupg', '~/.config/openclaw'] },
|
|
247
|
+
shell: { allowed: false, commands: [] },
|
|
248
|
+
credentials: [],
|
|
249
|
+
capabilities: {
|
|
250
|
+
browser: false,
|
|
251
|
+
messaging: false,
|
|
252
|
+
cron: false,
|
|
253
|
+
spawn_agents: false
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// Default resources
|
|
258
|
+
const defaultResources = {
|
|
259
|
+
max_memory_mb: 256,
|
|
260
|
+
max_cpu_percent: 25,
|
|
261
|
+
max_runtime_seconds: 60,
|
|
262
|
+
max_network_mb: 10
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
module.exports = {
|
|
266
|
+
manifestSchema,
|
|
267
|
+
defaultPermissions,
|
|
268
|
+
defaultResources
|
|
269
|
+
};
|
package/src/signing.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClaw Secure Signing Module
|
|
3
|
+
* Cryptographic signing and verification using Ed25519
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const nacl = require('tweetnacl');
|
|
7
|
+
const naclUtil = require('tweetnacl-util');
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
const yaml = require('yaml');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Generate a new Ed25519 keypair
|
|
13
|
+
* @returns {Object} - { publicKey: string, secretKey: string } (base64 encoded)
|
|
14
|
+
*/
|
|
15
|
+
function generateKeyPair() {
|
|
16
|
+
const keyPair = nacl.sign.keyPair();
|
|
17
|
+
return {
|
|
18
|
+
publicKey: naclUtil.encodeBase64(keyPair.publicKey),
|
|
19
|
+
secretKey: naclUtil.encodeBase64(keyPair.secretKey)
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Compute SHA256 hash of content
|
|
25
|
+
* @param {string} content - Content to hash
|
|
26
|
+
* @returns {string} - Hash in format "sha256:hexstring"
|
|
27
|
+
*/
|
|
28
|
+
function computeHash(content) {
|
|
29
|
+
const hash = crypto.createHash('sha256').update(content, 'utf8').digest('hex');
|
|
30
|
+
return `sha256:${hash}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Get the signable content from a manifest (excluding signature block)
|
|
35
|
+
* @param {Object} manifest - The manifest object
|
|
36
|
+
* @returns {string} - YAML string of content to sign
|
|
37
|
+
*/
|
|
38
|
+
function getSignableContent(manifest) {
|
|
39
|
+
// Clone and remove signature block
|
|
40
|
+
const { signature, ...contentToSign } = manifest;
|
|
41
|
+
|
|
42
|
+
// Sort keys for deterministic serialization
|
|
43
|
+
const sortedContent = sortObjectKeys(contentToSign);
|
|
44
|
+
|
|
45
|
+
// Serialize to YAML
|
|
46
|
+
return yaml.stringify(sortedContent);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Recursively sort object keys for deterministic serialization
|
|
51
|
+
*/
|
|
52
|
+
function sortObjectKeys(obj) {
|
|
53
|
+
if (Array.isArray(obj)) {
|
|
54
|
+
return obj.map(sortObjectKeys);
|
|
55
|
+
}
|
|
56
|
+
if (obj !== null && typeof obj === 'object') {
|
|
57
|
+
const sorted = {};
|
|
58
|
+
Object.keys(obj).sort().forEach(key => {
|
|
59
|
+
sorted[key] = sortObjectKeys(obj[key]);
|
|
60
|
+
});
|
|
61
|
+
return sorted;
|
|
62
|
+
}
|
|
63
|
+
return obj;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Sign a manifest with a secret key
|
|
68
|
+
* @param {Object} manifest - The manifest to sign
|
|
69
|
+
* @param {string} secretKeyBase64 - Base64-encoded secret key
|
|
70
|
+
* @param {string} signerName - Name/identity of the signer
|
|
71
|
+
* @returns {Object} - Manifest with signature block added
|
|
72
|
+
*/
|
|
73
|
+
function signManifest(manifest, secretKeyBase64, signerName) {
|
|
74
|
+
// Remove any existing signature
|
|
75
|
+
const { signature: _, ...contentToSign } = manifest;
|
|
76
|
+
|
|
77
|
+
// Get signable content and compute hash
|
|
78
|
+
const signableContent = getSignableContent(contentToSign);
|
|
79
|
+
const contentHash = computeHash(signableContent);
|
|
80
|
+
|
|
81
|
+
// Decode secret key
|
|
82
|
+
const secretKey = naclUtil.decodeBase64(secretKeyBase64);
|
|
83
|
+
|
|
84
|
+
// Extract public key from secret key (last 32 bytes of 64-byte secret key)
|
|
85
|
+
const publicKey = secretKey.slice(32);
|
|
86
|
+
|
|
87
|
+
// Sign the content hash
|
|
88
|
+
const messageBytes = naclUtil.decodeUTF8(contentHash);
|
|
89
|
+
const signatureBytes = nacl.sign.detached(messageBytes, secretKey);
|
|
90
|
+
|
|
91
|
+
// Create signature block
|
|
92
|
+
const signatureBlock = {
|
|
93
|
+
algorithm: 'ed25519',
|
|
94
|
+
signer: signerName,
|
|
95
|
+
signed_at: new Date().toISOString(),
|
|
96
|
+
public_key: naclUtil.encodeBase64(publicKey),
|
|
97
|
+
signature: naclUtil.encodeBase64(signatureBytes),
|
|
98
|
+
content_hash: contentHash
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Return manifest with signature
|
|
102
|
+
return {
|
|
103
|
+
...contentToSign,
|
|
104
|
+
signature: signatureBlock
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Verify a signed manifest
|
|
110
|
+
* @param {Object} manifest - The manifest with signature block
|
|
111
|
+
* @param {string} [expectedPublicKey] - Optional: expected public key (base64)
|
|
112
|
+
* @returns {Object} - { valid: boolean, error?: string, signer?: string }
|
|
113
|
+
*/
|
|
114
|
+
function verifyManifest(manifest) {
|
|
115
|
+
// Check if signature block exists
|
|
116
|
+
if (!manifest.signature) {
|
|
117
|
+
return { valid: false, error: 'No signature block found' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const { signature } = manifest;
|
|
121
|
+
|
|
122
|
+
// Validate signature block fields
|
|
123
|
+
if (!signature.algorithm || signature.algorithm !== 'ed25519') {
|
|
124
|
+
return { valid: false, error: 'Unsupported signature algorithm' };
|
|
125
|
+
}
|
|
126
|
+
if (!signature.public_key || !signature.signature || !signature.content_hash) {
|
|
127
|
+
return { valid: false, error: 'Incomplete signature block' };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
// Recompute content hash
|
|
132
|
+
const signableContent = getSignableContent(manifest);
|
|
133
|
+
const computedHash = computeHash(signableContent);
|
|
134
|
+
|
|
135
|
+
// Check content hash matches
|
|
136
|
+
if (computedHash !== signature.content_hash) {
|
|
137
|
+
return {
|
|
138
|
+
valid: false,
|
|
139
|
+
error: 'Content hash mismatch - manifest may have been modified',
|
|
140
|
+
expected: signature.content_hash,
|
|
141
|
+
computed: computedHash
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Decode keys and signature
|
|
146
|
+
const publicKey = naclUtil.decodeBase64(signature.public_key);
|
|
147
|
+
const signatureBytes = naclUtil.decodeBase64(signature.signature);
|
|
148
|
+
const messageBytes = naclUtil.decodeUTF8(signature.content_hash);
|
|
149
|
+
|
|
150
|
+
// Verify signature
|
|
151
|
+
const valid = nacl.sign.detached.verify(messageBytes, signatureBytes, publicKey);
|
|
152
|
+
|
|
153
|
+
if (valid) {
|
|
154
|
+
return {
|
|
155
|
+
valid: true,
|
|
156
|
+
signer: signature.signer,
|
|
157
|
+
signed_at: signature.signed_at,
|
|
158
|
+
public_key: signature.public_key
|
|
159
|
+
};
|
|
160
|
+
} else {
|
|
161
|
+
return { valid: false, error: 'Signature verification failed' };
|
|
162
|
+
}
|
|
163
|
+
} catch (err) {
|
|
164
|
+
return { valid: false, error: `Verification error: ${err.message}` };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Check if a manifest is signed
|
|
170
|
+
*/
|
|
171
|
+
function isSigned(manifest) {
|
|
172
|
+
return !!(manifest.signature && manifest.signature.signature);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Get the public key fingerprint (first 8 chars of base64)
|
|
177
|
+
*/
|
|
178
|
+
function getKeyFingerprint(publicKeyBase64) {
|
|
179
|
+
const hash = crypto.createHash('sha256')
|
|
180
|
+
.update(Buffer.from(publicKeyBase64, 'base64'))
|
|
181
|
+
.digest('hex');
|
|
182
|
+
return hash.substring(0, 16);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = {
|
|
186
|
+
generateKeyPair,
|
|
187
|
+
computeHash,
|
|
188
|
+
signManifest,
|
|
189
|
+
verifyManifest,
|
|
190
|
+
isSigned,
|
|
191
|
+
getSignableContent,
|
|
192
|
+
getKeyFingerprint
|
|
193
|
+
};
|