@hamzaabili/opencode-integrity 0.0.0-stage → 0.1.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/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +189 -2
- package/SECURITY.md +61 -0
- package/dist/cli.mjs +1260 -0
- package/index.ts +1 -0
- package/package.json +70 -4
- package/scripts/build.ts +23 -0
- package/scripts/smoke.ts +39 -0
- package/src/assets.ts +344 -0
- package/src/audit.ts +129 -0
- package/src/cli.ts +200 -0
- package/src/config.ts +123 -0
- package/src/index.ts +19 -0
- package/src/lockfile.ts +298 -0
- package/src/options.ts +55 -0
- package/src/policy.ts +25 -0
- package/src/runtime.ts +213 -0
- package/src/sarif.ts +71 -0
- package/src/tool.ts +53 -0
- package/src/types.ts +102 -0
- package/src/util.ts +349 -0
- package/src/v1.ts +121 -0
- package/src/v2.ts +385 -0
- package/src/version.ts +1 -0
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,1260 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { homedir as homedir2 } from "node:os";
|
|
5
|
+
import { relative as relative3, resolve as resolve4 } from "node:path";
|
|
6
|
+
|
|
7
|
+
// src/assets.ts
|
|
8
|
+
import { readdir as readdir2, realpath as realpath2 } from "node:fs/promises";
|
|
9
|
+
import { createRequire } from "node:module";
|
|
10
|
+
import { basename, dirname as dirname2, isAbsolute as isAbsolute2, join as join2, relative as relative2, resolve as resolve2 } from "node:path";
|
|
11
|
+
|
|
12
|
+
// src/util.ts
|
|
13
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
14
|
+
import {
|
|
15
|
+
mkdir,
|
|
16
|
+
readdir,
|
|
17
|
+
readFile,
|
|
18
|
+
readlink,
|
|
19
|
+
realpath,
|
|
20
|
+
rename,
|
|
21
|
+
stat,
|
|
22
|
+
writeFile
|
|
23
|
+
} from "node:fs/promises";
|
|
24
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
25
|
+
async function readTextFileCapped(path, maxBytes = 2 * 1024 * 1024) {
|
|
26
|
+
try {
|
|
27
|
+
const info = await stat(path);
|
|
28
|
+
if (info.size > maxBytes)
|
|
29
|
+
return;
|
|
30
|
+
} catch {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
return await readTextFile(path);
|
|
34
|
+
}
|
|
35
|
+
async function readTextFile(path) {
|
|
36
|
+
try {
|
|
37
|
+
return await readFile(path, "utf8");
|
|
38
|
+
} catch {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function readJsonFileCapped(path, maxBytes = 2 * 1024 * 1024) {
|
|
43
|
+
const text = await readTextFileCapped(path, maxBytes);
|
|
44
|
+
if (text === undefined)
|
|
45
|
+
return;
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(text);
|
|
48
|
+
} catch {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function readJsonFile(path) {
|
|
53
|
+
const text = await readTextFile(path);
|
|
54
|
+
if (text === undefined)
|
|
55
|
+
return;
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(text);
|
|
58
|
+
} catch {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function pathExists(path) {
|
|
63
|
+
try {
|
|
64
|
+
await stat(path);
|
|
65
|
+
return true;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function isDirectory(path) {
|
|
71
|
+
try {
|
|
72
|
+
return (await stat(path)).isDirectory();
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function writeFileAtomic(path, content, mode = 420) {
|
|
78
|
+
await mkdir(dirname(path), { recursive: true });
|
|
79
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
80
|
+
await writeFile(temporary, content, { mode });
|
|
81
|
+
await rename(temporary, path);
|
|
82
|
+
}
|
|
83
|
+
function sha256(value) {
|
|
84
|
+
return createHash("sha256").update(value).digest("hex");
|
|
85
|
+
}
|
|
86
|
+
function hashText(value) {
|
|
87
|
+
return `sha256:${sha256(value)}`;
|
|
88
|
+
}
|
|
89
|
+
function stableStringify(value) {
|
|
90
|
+
if (value === null || typeof value !== "object")
|
|
91
|
+
return JSON.stringify(value);
|
|
92
|
+
if (Array.isArray(value))
|
|
93
|
+
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
|
|
94
|
+
const record = value;
|
|
95
|
+
const keys = Object.keys(record).sort();
|
|
96
|
+
return `{${keys.filter((key) => record[key] !== undefined).map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
|
|
97
|
+
}
|
|
98
|
+
function hashObject(value) {
|
|
99
|
+
return hashText(stableStringify(value));
|
|
100
|
+
}
|
|
101
|
+
function isInside(parent, child) {
|
|
102
|
+
const rel = relative(resolve(parent), resolve(child));
|
|
103
|
+
return rel === "" || !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
|
|
104
|
+
}
|
|
105
|
+
var IGNORED_DIRS = new Set([".git"]);
|
|
106
|
+
async function hashDirectory(root, maxFiles = 5000, maxBytes = 50 * 1024 * 1024) {
|
|
107
|
+
const files = [];
|
|
108
|
+
const visited = new Set;
|
|
109
|
+
let bytes = 0;
|
|
110
|
+
let truncated = false;
|
|
111
|
+
async function walk(directory, depth) {
|
|
112
|
+
if (truncated)
|
|
113
|
+
return;
|
|
114
|
+
if (depth > 32) {
|
|
115
|
+
truncated = true;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
let real;
|
|
119
|
+
try {
|
|
120
|
+
real = await realpath(directory);
|
|
121
|
+
} catch {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (visited.has(real))
|
|
125
|
+
return;
|
|
126
|
+
visited.add(real);
|
|
127
|
+
let entries;
|
|
128
|
+
try {
|
|
129
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
130
|
+
} catch {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
134
|
+
for (const entry of entries) {
|
|
135
|
+
if (files.length >= maxFiles || bytes >= maxBytes) {
|
|
136
|
+
truncated = true;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (entry.isDirectory()) {
|
|
140
|
+
if (IGNORED_DIRS.has(entry.name))
|
|
141
|
+
continue;
|
|
142
|
+
await walk(join(directory, entry.name), depth + 1);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const path = join(directory, entry.name);
|
|
146
|
+
if (entry.isSymbolicLink()) {
|
|
147
|
+
let target = "";
|
|
148
|
+
try {
|
|
149
|
+
target = await readlink(path);
|
|
150
|
+
} catch {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
let content = Buffer.from(`link:${target}`);
|
|
154
|
+
try {
|
|
155
|
+
const targetPath = resolve(dirname(path), target);
|
|
156
|
+
const info = await stat(targetPath);
|
|
157
|
+
if (info.isDirectory()) {
|
|
158
|
+
await walk(targetPath, depth + 1);
|
|
159
|
+
} else if (info.isFile()) {
|
|
160
|
+
if (info.size > maxBytes - bytes) {
|
|
161
|
+
truncated = true;
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const targetContent = await readFile(targetPath);
|
|
165
|
+
bytes += targetContent.byteLength;
|
|
166
|
+
content = Buffer.from(`link:${target}\x00${sha256(targetContent.toString("base64"))}`);
|
|
167
|
+
}
|
|
168
|
+
} catch {}
|
|
169
|
+
files.push({ path: relative(root, path), content });
|
|
170
|
+
bytes += content.byteLength;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (!entry.isFile())
|
|
174
|
+
continue;
|
|
175
|
+
try {
|
|
176
|
+
const info = await stat(path);
|
|
177
|
+
if (info.size > maxBytes - bytes) {
|
|
178
|
+
truncated = true;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const content = await readFile(path);
|
|
182
|
+
files.push({ path: relative(root, path), content });
|
|
183
|
+
bytes += content.byteLength;
|
|
184
|
+
} catch {}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
await walk(root, 0);
|
|
188
|
+
const lines = files.sort((a, b) => a.path.localeCompare(b.path)).map((file) => `${file.path}\x00${sha256(file.content.toString("base64"))}`);
|
|
189
|
+
return {
|
|
190
|
+
hash: hashText(lines.join(`
|
|
191
|
+
`)),
|
|
192
|
+
files: files.length,
|
|
193
|
+
bytes,
|
|
194
|
+
truncated
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function parseJsonc(text) {
|
|
198
|
+
let output = "";
|
|
199
|
+
let inString = false;
|
|
200
|
+
let escaped = false;
|
|
201
|
+
for (let index = 0;index < text.length; index += 1) {
|
|
202
|
+
const character = text[index] ?? "";
|
|
203
|
+
const next = text[index + 1] ?? "";
|
|
204
|
+
if (inString) {
|
|
205
|
+
output += character;
|
|
206
|
+
if (escaped)
|
|
207
|
+
escaped = false;
|
|
208
|
+
else if (character === "\\")
|
|
209
|
+
escaped = true;
|
|
210
|
+
else if (character === '"')
|
|
211
|
+
inString = false;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (character === '"') {
|
|
215
|
+
inString = true;
|
|
216
|
+
output += character;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (character === "/" && next === "/") {
|
|
220
|
+
while (index < text.length && text[index] !== `
|
|
221
|
+
`)
|
|
222
|
+
index += 1;
|
|
223
|
+
output += `
|
|
224
|
+
`;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (character === "/" && next === "*") {
|
|
228
|
+
index += 2;
|
|
229
|
+
while (index < text.length && !(text[index] === "*" && text[index + 1] === "/"))
|
|
230
|
+
index += 1;
|
|
231
|
+
index += 1;
|
|
232
|
+
output += " ";
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
output += character;
|
|
236
|
+
}
|
|
237
|
+
let cleaned = "";
|
|
238
|
+
let string = false;
|
|
239
|
+
let escapedInString = false;
|
|
240
|
+
for (let index = 0;index < output.length; index += 1) {
|
|
241
|
+
const character = output[index] ?? "";
|
|
242
|
+
if (string) {
|
|
243
|
+
cleaned += character;
|
|
244
|
+
if (escapedInString)
|
|
245
|
+
escapedInString = false;
|
|
246
|
+
else if (character === "\\")
|
|
247
|
+
escapedInString = true;
|
|
248
|
+
else if (character === '"')
|
|
249
|
+
string = false;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (character === '"') {
|
|
253
|
+
string = true;
|
|
254
|
+
cleaned += character;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (character === ",") {
|
|
258
|
+
let next = index + 1;
|
|
259
|
+
while (next < output.length && /\s/.test(output[next] ?? ""))
|
|
260
|
+
next += 1;
|
|
261
|
+
const following = output[next] ?? "";
|
|
262
|
+
if (following === "}" || following === "]")
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
cleaned += character;
|
|
266
|
+
}
|
|
267
|
+
return JSON.parse(cleaned);
|
|
268
|
+
}
|
|
269
|
+
async function readJsoncFile(path) {
|
|
270
|
+
const text = await readTextFile(path);
|
|
271
|
+
if (text === undefined)
|
|
272
|
+
return;
|
|
273
|
+
try {
|
|
274
|
+
return parseJsonc(text);
|
|
275
|
+
} catch {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
function globMatch(pattern, value) {
|
|
280
|
+
let source = "";
|
|
281
|
+
for (let index = 0;index < pattern.length; index += 1) {
|
|
282
|
+
const character = pattern[index] ?? "";
|
|
283
|
+
if (character === "*")
|
|
284
|
+
source += ".*";
|
|
285
|
+
else if (character === "?")
|
|
286
|
+
source += ".";
|
|
287
|
+
else
|
|
288
|
+
source += character.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
return new RegExp(`^${source}$`, "s").test(value);
|
|
292
|
+
} catch {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function ignored(id, patterns) {
|
|
297
|
+
return patterns.some((pattern) => globMatch(pattern, id));
|
|
298
|
+
}
|
|
299
|
+
var CAPABILITY_PATTERNS = [
|
|
300
|
+
["network", /\b(fetch\(|node:https?|from ["']https?["']|undici|WebSocket|axios)/],
|
|
301
|
+
["filesystem", /\b(node:fs|from ["']fs["']|readFile|writeFile|readdir|mkdir)/],
|
|
302
|
+
["process", /\b(child_process|spawn\(|execFile\(|exec\()/],
|
|
303
|
+
["environment", /process\.env/],
|
|
304
|
+
["dynamic-code", /\b(eval\(|new Function\()/]
|
|
305
|
+
];
|
|
306
|
+
function detectCapabilities(text) {
|
|
307
|
+
return CAPABILITY_PATTERNS.filter(([, pattern]) => pattern.test(text)).map(([name]) => name);
|
|
308
|
+
}
|
|
309
|
+
var SUSPICIOUS_PATTERNS = [
|
|
310
|
+
["zero-width-characters", /[\u200B-\u200D\uFEFF]/],
|
|
311
|
+
["bidi-controls", /[\u202A-\u202E\u2066-\u2069]/],
|
|
312
|
+
["html-comment", /<!--/],
|
|
313
|
+
[
|
|
314
|
+
"ignore-instructions",
|
|
315
|
+
/\b(ignore|disregard)\s+(all\s+)?(previous|prior|above)\s+instructions?\b/i
|
|
316
|
+
],
|
|
317
|
+
["do-not-tell", /\bdo\s+not\s+(tell|inform|mention)\b/i],
|
|
318
|
+
["exfiltration-language", /\b(exfiltrate|send\s+.*\b(token|secret|credential|env)\b)/i]
|
|
319
|
+
];
|
|
320
|
+
function detectSuspicious(text) {
|
|
321
|
+
return SUSPICIOUS_PATTERNS.filter(([, pattern]) => pattern.test(text)).map(([name]) => name);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// src/assets.ts
|
|
325
|
+
function isObject(value) {
|
|
326
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
327
|
+
}
|
|
328
|
+
function secretKey(key) {
|
|
329
|
+
return /(token|secret|password|api[-_]?key|authorization|cookie|credential)/i.test(key);
|
|
330
|
+
}
|
|
331
|
+
function redactValue(key, value) {
|
|
332
|
+
return secretKey(key) ? "[redacted]" : value;
|
|
333
|
+
}
|
|
334
|
+
function redactRecord(value) {
|
|
335
|
+
if (!isObject(value))
|
|
336
|
+
return {};
|
|
337
|
+
const output = {};
|
|
338
|
+
for (const [key, entry] of Object.entries(value))
|
|
339
|
+
output[key] = redactValue(key, entry);
|
|
340
|
+
return output;
|
|
341
|
+
}
|
|
342
|
+
function redactCommand(command) {
|
|
343
|
+
return command.map((entry) => entry.replace(/((?:token|secret|password|api[-_]?key)=)[^\s]+/gi, "$1[redacted]"));
|
|
344
|
+
}
|
|
345
|
+
function sanitizeUrl(value) {
|
|
346
|
+
try {
|
|
347
|
+
return new URL(value).origin;
|
|
348
|
+
} catch {
|
|
349
|
+
return "[invalid-url]";
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function packageJsonPath(root) {
|
|
353
|
+
return join2(root, "package.json");
|
|
354
|
+
}
|
|
355
|
+
async function walkUpToPackage(start) {
|
|
356
|
+
let current = resolve2(start);
|
|
357
|
+
for (let depth = 0;depth < 8; depth += 1) {
|
|
358
|
+
if (await pathExists(packageJsonPath(current)))
|
|
359
|
+
return current;
|
|
360
|
+
const parent = dirname2(current);
|
|
361
|
+
if (parent === current)
|
|
362
|
+
break;
|
|
363
|
+
current = parent;
|
|
364
|
+
}
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
async function resolvePluginRoot(ref, directory, home) {
|
|
368
|
+
const target = ref.localPath ?? ref.target;
|
|
369
|
+
if (isAbsolute2(target) || target.startsWith(".") || target.startsWith("~/")) {
|
|
370
|
+
const expanded = target.startsWith("~") ? resolve2(home, target.slice(2)) : resolve2(directory, target);
|
|
371
|
+
if (await isDirectory(expanded))
|
|
372
|
+
return expanded;
|
|
373
|
+
return await walkUpToPackage(dirname2(expanded));
|
|
374
|
+
}
|
|
375
|
+
const direct = join2(directory, "node_modules", target);
|
|
376
|
+
if (await isDirectory(direct))
|
|
377
|
+
return direct;
|
|
378
|
+
try {
|
|
379
|
+
const require2 = createRequire(join2(directory, "package.json"));
|
|
380
|
+
const entry = require2.resolve(target);
|
|
381
|
+
return await walkUpToPackage(dirname2(entry));
|
|
382
|
+
} catch {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
async function entryText(root, realRoot) {
|
|
387
|
+
const manifest = await readJsonFileCapped(packageJsonPath(root));
|
|
388
|
+
const candidates = new Set;
|
|
389
|
+
if (typeof manifest?.main === "string")
|
|
390
|
+
candidates.add(manifest.main);
|
|
391
|
+
if (typeof manifest?.module === "string")
|
|
392
|
+
candidates.add(manifest.module);
|
|
393
|
+
const exports = manifest?.exports;
|
|
394
|
+
if (typeof exports === "string")
|
|
395
|
+
candidates.add(exports);
|
|
396
|
+
else if (isObject(exports)) {
|
|
397
|
+
for (const value of Object.values(exports)) {
|
|
398
|
+
if (typeof value === "string")
|
|
399
|
+
candidates.add(value);
|
|
400
|
+
else if (isObject(value)) {
|
|
401
|
+
for (const nested of Object.values(value))
|
|
402
|
+
if (typeof nested === "string")
|
|
403
|
+
candidates.add(nested);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
candidates.add("index.ts");
|
|
408
|
+
candidates.add("index.js");
|
|
409
|
+
for (const candidate of candidates) {
|
|
410
|
+
const path = join2(root, candidate);
|
|
411
|
+
if (!isInside(root, path))
|
|
412
|
+
continue;
|
|
413
|
+
const real = await realpath2(path).catch(() => {
|
|
414
|
+
return;
|
|
415
|
+
});
|
|
416
|
+
if (real === undefined || !isInside(realRoot, real))
|
|
417
|
+
continue;
|
|
418
|
+
const text = await readTextFileCapped(real);
|
|
419
|
+
if (text !== undefined)
|
|
420
|
+
return text;
|
|
421
|
+
}
|
|
422
|
+
return "";
|
|
423
|
+
}
|
|
424
|
+
async function pluginAsset(id, ref, directory, home, allowOutside = false) {
|
|
425
|
+
const root = await resolvePluginRoot(ref, directory, home);
|
|
426
|
+
if (root === undefined) {
|
|
427
|
+
return {
|
|
428
|
+
warning: `${id}: package not installed (${ref.target}); run the package manager first`
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
const realRoot = await realpath2(root).catch(() => root);
|
|
432
|
+
const realDirectory = await realpath2(directory).catch(() => directory);
|
|
433
|
+
if (!allowOutside && !isInside(realDirectory, realRoot)) {
|
|
434
|
+
return { warning: `${id}: local plugin resolves outside the project and was ignored` };
|
|
435
|
+
}
|
|
436
|
+
const manifest = await readJsonFileCapped(packageJsonPath(realRoot));
|
|
437
|
+
const version = ref.version ?? (typeof manifest?.version === "string" ? manifest.version : undefined);
|
|
438
|
+
const content = await hashDirectory(root);
|
|
439
|
+
const text = await entryText(root, realRoot);
|
|
440
|
+
return {
|
|
441
|
+
asset: {
|
|
442
|
+
source: ref.target,
|
|
443
|
+
version,
|
|
444
|
+
packageHash: hashObject(manifest ?? {}),
|
|
445
|
+
contentHash: content.hash,
|
|
446
|
+
files: content.files,
|
|
447
|
+
bytes: content.bytes,
|
|
448
|
+
capabilities: detectCapabilities(text),
|
|
449
|
+
truncated: content.truncated
|
|
450
|
+
},
|
|
451
|
+
warning: content.truncated ? `${id}: hash truncated at 5000 files / 50 MiB` : undefined
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
async function skillFiles(root) {
|
|
455
|
+
const output = [];
|
|
456
|
+
async function walk(directory, depth) {
|
|
457
|
+
if (depth > 4)
|
|
458
|
+
return;
|
|
459
|
+
let entries;
|
|
460
|
+
try {
|
|
461
|
+
entries = await readdir2(directory, { withFileTypes: true });
|
|
462
|
+
} catch {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
for (const entry of entries) {
|
|
466
|
+
if (entry.name === "node_modules" || entry.name === ".git")
|
|
467
|
+
continue;
|
|
468
|
+
const path = join2(directory, entry.name);
|
|
469
|
+
if (entry.isDirectory())
|
|
470
|
+
await walk(path, depth + 1);
|
|
471
|
+
else if (entry.isFile() && entry.name === "SKILL.md")
|
|
472
|
+
output.push(path);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
await walk(root, 0);
|
|
476
|
+
return output.sort();
|
|
477
|
+
}
|
|
478
|
+
function skillID(path, content) {
|
|
479
|
+
const match = /^---\s*\n([\s\S]*?)\n---/.exec(content);
|
|
480
|
+
const name = match?.[1]?.match(/^name:\s*["']?([^"'\n]+)["']?/m)?.[1];
|
|
481
|
+
return (name ?? basename(dirname2(path))).trim();
|
|
482
|
+
}
|
|
483
|
+
async function scanSkills(directory, warnings = []) {
|
|
484
|
+
const root = join2(directory, ".opencode", "skills");
|
|
485
|
+
const output = {};
|
|
486
|
+
for (const path of await skillFiles(root)) {
|
|
487
|
+
const content = await readTextFileCapped(path);
|
|
488
|
+
if (content === undefined) {
|
|
489
|
+
warnings.push(`${path}: skill too large or unreadable; not hashed`);
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
const directoryHash = await hashDirectory(dirname2(path));
|
|
493
|
+
if (directoryHash.truncated)
|
|
494
|
+
warnings.push(`${path}: skill directory too large to hash completely`);
|
|
495
|
+
output[skillID(path, content)] = {
|
|
496
|
+
path: path.slice(directory.length + 1),
|
|
497
|
+
contentHash: hashText(content),
|
|
498
|
+
directoryHash: directoryHash.hash,
|
|
499
|
+
files: directoryHash.files,
|
|
500
|
+
bytes: directoryHash.bytes,
|
|
501
|
+
truncated: directoryHash.truncated,
|
|
502
|
+
suspicious: detectSuspicious(content)
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
return output;
|
|
506
|
+
}
|
|
507
|
+
function mcpConfigAsset(_name, config) {
|
|
508
|
+
if (!isObject(config))
|
|
509
|
+
return;
|
|
510
|
+
const type = typeof config.type === "string" ? config.type : typeof config.url === "string" ? "remote" : "local";
|
|
511
|
+
const transport = type === "remote" ? "remote" : "local";
|
|
512
|
+
if (config.disabled === true)
|
|
513
|
+
return;
|
|
514
|
+
const commandValue = config.command;
|
|
515
|
+
const command = Array.isArray(commandValue) ? commandValue.filter((entry) => typeof entry === "string") : typeof commandValue === "string" ? commandValue.trim().split(/\s+/) : [];
|
|
516
|
+
const environment = redactRecord(config.environment ?? config.env);
|
|
517
|
+
const headers = redactRecord(config.headers);
|
|
518
|
+
const canonical = {
|
|
519
|
+
type,
|
|
520
|
+
command: redactCommand(command),
|
|
521
|
+
cwd: typeof config.cwd === "string" ? config.cwd : undefined,
|
|
522
|
+
url: typeof config.url === "string" ? config.url : undefined,
|
|
523
|
+
environment,
|
|
524
|
+
headers,
|
|
525
|
+
codemode: config.codemode
|
|
526
|
+
};
|
|
527
|
+
return {
|
|
528
|
+
transport,
|
|
529
|
+
configHash: hashObject(canonical),
|
|
530
|
+
command: command[0] === undefined ? undefined : basename(command[0]),
|
|
531
|
+
url: typeof config.url === "string" ? sanitizeUrl(config.url) : undefined,
|
|
532
|
+
envKeys: Object.keys(environment).sort(),
|
|
533
|
+
toolsSource: "none"
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// src/config.ts
|
|
538
|
+
import { join as join3 } from "node:path";
|
|
539
|
+
function isObject2(value) {
|
|
540
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
541
|
+
}
|
|
542
|
+
function pluginRef(value) {
|
|
543
|
+
if (typeof value === "string") {
|
|
544
|
+
return value.trim().length > 0 ? { target: value.trim() } : undefined;
|
|
545
|
+
}
|
|
546
|
+
if (Array.isArray(value) && typeof value[0] === "string") {
|
|
547
|
+
return { target: value[0] };
|
|
548
|
+
}
|
|
549
|
+
if (isObject2(value) && typeof value.package === "string") {
|
|
550
|
+
return { target: value.package };
|
|
551
|
+
}
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
function configPaths(directory) {
|
|
555
|
+
return [
|
|
556
|
+
join3(directory, "opencode.json"),
|
|
557
|
+
join3(directory, "opencode.jsonc"),
|
|
558
|
+
join3(directory, ".opencode", "opencode.json"),
|
|
559
|
+
join3(directory, ".opencode", "opencode.jsonc")
|
|
560
|
+
];
|
|
561
|
+
}
|
|
562
|
+
function mcpFromConfig(data) {
|
|
563
|
+
const output = {};
|
|
564
|
+
if (!isObject2(data.mcp))
|
|
565
|
+
return output;
|
|
566
|
+
if (isObject2(data.mcp.servers))
|
|
567
|
+
Object.assign(output, data.mcp.servers);
|
|
568
|
+
for (const [name, value] of Object.entries(data.mcp)) {
|
|
569
|
+
if (name === "servers" || name === "timeout")
|
|
570
|
+
continue;
|
|
571
|
+
if (isObject2(value))
|
|
572
|
+
output[name] = value;
|
|
573
|
+
}
|
|
574
|
+
return output;
|
|
575
|
+
}
|
|
576
|
+
async function loadGlobalMcp(home) {
|
|
577
|
+
const output = {};
|
|
578
|
+
for (const path of [
|
|
579
|
+
join3(home, ".config", "opencode", "opencode.json"),
|
|
580
|
+
join3(home, ".config", "opencode", "opencode.jsonc")
|
|
581
|
+
]) {
|
|
582
|
+
if (!await pathExists(path))
|
|
583
|
+
continue;
|
|
584
|
+
const data = await readJsoncFile(path);
|
|
585
|
+
if (isObject2(data))
|
|
586
|
+
Object.assign(output, mcpFromConfig(data));
|
|
587
|
+
}
|
|
588
|
+
return output;
|
|
589
|
+
}
|
|
590
|
+
async function loadProjectConfig(directory) {
|
|
591
|
+
const files = [];
|
|
592
|
+
const plugins = [];
|
|
593
|
+
const mcp = {};
|
|
594
|
+
const warnings = [];
|
|
595
|
+
for (const path of configPaths(directory)) {
|
|
596
|
+
if (!await pathExists(path))
|
|
597
|
+
continue;
|
|
598
|
+
const data = await readJsoncFile(path);
|
|
599
|
+
if (!isObject2(data)) {
|
|
600
|
+
warnings.push(`${path}: unreadable or invalid JSONC`);
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
files.push(path);
|
|
604
|
+
const values = [
|
|
605
|
+
...Array.isArray(data.plugins) ? data.plugins : [],
|
|
606
|
+
...Array.isArray(data.plugin) ? data.plugin : []
|
|
607
|
+
];
|
|
608
|
+
for (const value of values) {
|
|
609
|
+
const ref = pluginRef(value);
|
|
610
|
+
if (ref !== undefined)
|
|
611
|
+
plugins.push(ref);
|
|
612
|
+
}
|
|
613
|
+
Object.assign(mcp, mcpFromConfig(data));
|
|
614
|
+
}
|
|
615
|
+
const pluginDirectory = join3(directory, ".opencode", "plugins");
|
|
616
|
+
if (await isDirectory(pluginDirectory)) {
|
|
617
|
+
const { readdir } = await import("node:fs/promises");
|
|
618
|
+
for (const entry of await readdir(pluginDirectory, { withFileTypes: true })) {
|
|
619
|
+
if (entry.isDirectory())
|
|
620
|
+
plugins.push({
|
|
621
|
+
target: join3(pluginDirectory, entry.name),
|
|
622
|
+
localPath: join3(pluginDirectory, entry.name)
|
|
623
|
+
});
|
|
624
|
+
else if (entry.isFile() && /\.(ts|js|mjs|cjs)$/.test(entry.name)) {
|
|
625
|
+
plugins.push({
|
|
626
|
+
target: join3(pluginDirectory, entry.name),
|
|
627
|
+
localPath: join3(pluginDirectory, entry.name)
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return { files, plugins: dedupe(plugins), mcp, warnings };
|
|
633
|
+
}
|
|
634
|
+
function dedupe(values) {
|
|
635
|
+
const seen = new Set;
|
|
636
|
+
return values.filter((value) => {
|
|
637
|
+
if (seen.has(value.target))
|
|
638
|
+
return false;
|
|
639
|
+
seen.add(value.target);
|
|
640
|
+
return true;
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// src/types.ts
|
|
645
|
+
var SCHEMA_VERSION = 1;
|
|
646
|
+
|
|
647
|
+
// src/lockfile.ts
|
|
648
|
+
function sortRecord(value) {
|
|
649
|
+
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
|
|
650
|
+
}
|
|
651
|
+
function sortAssets(assets) {
|
|
652
|
+
return {
|
|
653
|
+
plugins: sortRecord(assets.plugins),
|
|
654
|
+
mcp: Object.fromEntries(Object.entries(assets.mcp).sort(([a], [b]) => a.localeCompare(b)).map(([name, asset]) => [
|
|
655
|
+
name,
|
|
656
|
+
{ ...asset, tools: asset.tools === undefined ? undefined : sortRecord(asset.tools) }
|
|
657
|
+
])),
|
|
658
|
+
skills: sortRecord(assets.skills)
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
function createLockfile(assets, opencode, version = "0.1.0") {
|
|
662
|
+
return {
|
|
663
|
+
schemaVersion: SCHEMA_VERSION,
|
|
664
|
+
generatedBy: `opencode-integrity@${version}`,
|
|
665
|
+
opencode,
|
|
666
|
+
assets: sortAssets(assets)
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
async function readLockfile(path) {
|
|
670
|
+
const raw = await readJsonFile(path);
|
|
671
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
672
|
+
return;
|
|
673
|
+
const record = raw;
|
|
674
|
+
if (record.schemaVersion !== SCHEMA_VERSION || typeof record.assets !== "object")
|
|
675
|
+
return;
|
|
676
|
+
const assets = record.assets;
|
|
677
|
+
return {
|
|
678
|
+
schemaVersion: SCHEMA_VERSION,
|
|
679
|
+
generatedBy: typeof record.generatedBy === "string" ? record.generatedBy : "unknown",
|
|
680
|
+
opencode: typeof record.opencode === "string" ? record.opencode : undefined,
|
|
681
|
+
assets: {
|
|
682
|
+
plugins: isRecord(assets.plugins) ? assets.plugins : {},
|
|
683
|
+
mcp: isRecord(assets.mcp) ? assets.mcp : {},
|
|
684
|
+
skills: isRecord(assets.skills) ? assets.skills : {}
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
async function writeLockfile(path, lockfile) {
|
|
689
|
+
const canonical = JSON.parse(stableStringify(lockfile));
|
|
690
|
+
await writeFileAtomic(path, `${JSON.stringify(canonical, null, 2)}
|
|
691
|
+
`);
|
|
692
|
+
}
|
|
693
|
+
function isRecord(value) {
|
|
694
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
695
|
+
}
|
|
696
|
+
function diffAssets(locked, current, compareTools = true) {
|
|
697
|
+
const drift = [];
|
|
698
|
+
for (const [id, asset] of Object.entries(current.plugins)) {
|
|
699
|
+
const before = locked.plugins[id];
|
|
700
|
+
if (before === undefined) {
|
|
701
|
+
drift.push({
|
|
702
|
+
asset: "plugin",
|
|
703
|
+
id,
|
|
704
|
+
change: "added",
|
|
705
|
+
severity: "high",
|
|
706
|
+
message: `new plugin ${id}`
|
|
707
|
+
});
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
if (before.truncated === true || asset.truncated === true) {
|
|
711
|
+
drift.push({
|
|
712
|
+
asset: "plugin",
|
|
713
|
+
id,
|
|
714
|
+
change: "changed",
|
|
715
|
+
field: "hash incomplete",
|
|
716
|
+
severity: "high",
|
|
717
|
+
message: `plugin ${id}: package too large to hash completely; reduce it or inspect manually`
|
|
718
|
+
});
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
if (before.contentHash !== asset.contentHash || before.packageHash !== asset.packageHash) {
|
|
722
|
+
const fields = [];
|
|
723
|
+
if (before.version !== asset.version)
|
|
724
|
+
fields.push(`version ${before.version ?? "?"} -> ${asset.version ?? "?"}`);
|
|
725
|
+
if (before.contentHash !== asset.contentHash)
|
|
726
|
+
fields.push("package content changed");
|
|
727
|
+
drift.push({
|
|
728
|
+
asset: "plugin",
|
|
729
|
+
id,
|
|
730
|
+
change: "changed",
|
|
731
|
+
field: fields.join(", "),
|
|
732
|
+
severity: "high",
|
|
733
|
+
message: `plugin ${id}: ${fields.join(", ") || "content changed"}`
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
const added = (asset.capabilities ?? []).filter((capability) => !(before.capabilities ?? []).includes(capability));
|
|
737
|
+
if (added.length > 0) {
|
|
738
|
+
drift.push({
|
|
739
|
+
asset: "plugin",
|
|
740
|
+
id,
|
|
741
|
+
change: "changed",
|
|
742
|
+
field: `capabilities +${added.join(",")}`,
|
|
743
|
+
severity: "high",
|
|
744
|
+
message: `plugin ${id}: new capabilities ${added.join(", ")}`
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
for (const id of Object.keys(locked.plugins)) {
|
|
749
|
+
if (current.plugins[id] === undefined) {
|
|
750
|
+
drift.push({
|
|
751
|
+
asset: "plugin",
|
|
752
|
+
id,
|
|
753
|
+
change: "removed",
|
|
754
|
+
severity: "low",
|
|
755
|
+
message: `plugin ${id} removed`
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
for (const [id, asset] of Object.entries(current.mcp)) {
|
|
760
|
+
const before = locked.mcp[id];
|
|
761
|
+
if (before === undefined) {
|
|
762
|
+
drift.push({
|
|
763
|
+
asset: "mcp",
|
|
764
|
+
id,
|
|
765
|
+
change: "added",
|
|
766
|
+
severity: "high",
|
|
767
|
+
message: `new MCP server ${id}`
|
|
768
|
+
});
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
if (before.configHash !== asset.configHash) {
|
|
772
|
+
drift.push({
|
|
773
|
+
asset: "mcp",
|
|
774
|
+
id,
|
|
775
|
+
change: "changed",
|
|
776
|
+
field: "config",
|
|
777
|
+
severity: "medium",
|
|
778
|
+
message: `MCP server ${id}: command, URL or environment changed`
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
if (!compareTools)
|
|
782
|
+
continue;
|
|
783
|
+
const beforeTools = before.tools ?? {};
|
|
784
|
+
const currentTools = asset.tools ?? {};
|
|
785
|
+
for (const [tool, toolAsset] of Object.entries(currentTools)) {
|
|
786
|
+
const previous = beforeTools[tool];
|
|
787
|
+
if (previous === undefined) {
|
|
788
|
+
drift.push({
|
|
789
|
+
asset: "mcp-tool",
|
|
790
|
+
id: tool,
|
|
791
|
+
parent: id,
|
|
792
|
+
change: "added",
|
|
793
|
+
severity: "high",
|
|
794
|
+
message: `MCP server ${id}: new tool ${tool}${(toolAsset.suspicious ?? []).length > 0 ? ` [suspicious: ${(toolAsset.suspicious ?? []).join(", ")}]` : ""}`
|
|
795
|
+
});
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
const fields = [];
|
|
799
|
+
if (previous.descriptionHash !== toolAsset.descriptionHash)
|
|
800
|
+
fields.push("description");
|
|
801
|
+
if (previous.inputHash !== toolAsset.inputHash)
|
|
802
|
+
fields.push("input schema");
|
|
803
|
+
if (fields.length > 0 || previous.descriptorHash !== toolAsset.descriptorHash) {
|
|
804
|
+
drift.push({
|
|
805
|
+
asset: "mcp-tool",
|
|
806
|
+
id: tool,
|
|
807
|
+
parent: id,
|
|
808
|
+
change: "changed",
|
|
809
|
+
field: fields.join(", ") || "descriptor",
|
|
810
|
+
severity: "high",
|
|
811
|
+
message: `MCP server ${id}: tool ${tool} changed (${fields.join(", ") || "descriptor"})${(toolAsset.suspicious ?? []).length > 0 ? ` [suspicious: ${(toolAsset.suspicious ?? []).join(", ")}]` : ""}`
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
for (const tool of Object.keys(beforeTools)) {
|
|
816
|
+
if (currentTools[tool] === undefined) {
|
|
817
|
+
drift.push({
|
|
818
|
+
asset: "mcp-tool",
|
|
819
|
+
id: tool,
|
|
820
|
+
parent: id,
|
|
821
|
+
change: "removed",
|
|
822
|
+
severity: "medium",
|
|
823
|
+
message: `MCP server ${id}: tool ${tool} removed`
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
for (const id of Object.keys(locked.mcp)) {
|
|
829
|
+
if (current.mcp[id] === undefined) {
|
|
830
|
+
drift.push({
|
|
831
|
+
asset: "mcp",
|
|
832
|
+
id,
|
|
833
|
+
change: "removed",
|
|
834
|
+
severity: "medium",
|
|
835
|
+
message: `MCP server ${id} removed`
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
for (const [id, asset] of Object.entries(current.skills)) {
|
|
840
|
+
const before = locked.skills[id];
|
|
841
|
+
if (before === undefined) {
|
|
842
|
+
drift.push({
|
|
843
|
+
asset: "skill",
|
|
844
|
+
id,
|
|
845
|
+
change: "added",
|
|
846
|
+
severity: "high",
|
|
847
|
+
message: `new skill ${id}`
|
|
848
|
+
});
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
if (before.truncated === true || asset.truncated === true) {
|
|
852
|
+
drift.push({
|
|
853
|
+
asset: "skill",
|
|
854
|
+
id,
|
|
855
|
+
change: "changed",
|
|
856
|
+
field: "hash incomplete",
|
|
857
|
+
severity: "high",
|
|
858
|
+
message: `skill ${id}: directory too large to hash completely; inspect manually`
|
|
859
|
+
});
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
if (before.contentHash !== asset.contentHash || before.path !== asset.path || before.directoryHash !== asset.directoryHash) {
|
|
863
|
+
const field = before.contentHash !== asset.contentHash ? "content" : before.path !== asset.path ? "path" : "files";
|
|
864
|
+
drift.push({
|
|
865
|
+
asset: "skill",
|
|
866
|
+
id,
|
|
867
|
+
change: "changed",
|
|
868
|
+
field,
|
|
869
|
+
severity: "high",
|
|
870
|
+
message: `skill ${id}: ${field} changed${(asset.suspicious ?? []).length > 0 ? ` [suspicious: ${(asset.suspicious ?? []).join(", ")}]` : ""}`
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
for (const id of Object.keys(locked.skills)) {
|
|
875
|
+
if (current.skills[id] === undefined) {
|
|
876
|
+
drift.push({
|
|
877
|
+
asset: "skill",
|
|
878
|
+
id,
|
|
879
|
+
change: "removed",
|
|
880
|
+
severity: "low",
|
|
881
|
+
message: `skill ${id} removed`
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
return drift.sort((a, b) => a.asset.localeCompare(b.asset) || a.id.localeCompare(b.id));
|
|
886
|
+
}
|
|
887
|
+
function audit(locked, current, warnings, compareTools = true) {
|
|
888
|
+
if (locked === undefined) {
|
|
889
|
+
return { locked: false, ok: true, drift: [], warnings, assets: current };
|
|
890
|
+
}
|
|
891
|
+
const drift = diffAssets(locked, current, compareTools);
|
|
892
|
+
return {
|
|
893
|
+
locked: true,
|
|
894
|
+
ok: drift.length === 0,
|
|
895
|
+
drift,
|
|
896
|
+
warnings,
|
|
897
|
+
assets: current,
|
|
898
|
+
lockedAssets: locked
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
function formatDrift(drift) {
|
|
902
|
+
if (drift.length === 0)
|
|
903
|
+
return "no drift";
|
|
904
|
+
return drift.map((entry) => {
|
|
905
|
+
const icon = entry.change === "added" ? "+" : entry.change === "removed" ? "-" : "~";
|
|
906
|
+
return `${icon} [${entry.severity}] ${entry.message}`;
|
|
907
|
+
}).join(`
|
|
908
|
+
`);
|
|
909
|
+
}
|
|
910
|
+
function severityCounts(drift) {
|
|
911
|
+
const output = { high: 0, medium: 0, low: 0 };
|
|
912
|
+
for (const entry of drift)
|
|
913
|
+
output[entry.severity] = (output[entry.severity] ?? 0) + 1;
|
|
914
|
+
return output;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// src/audit.ts
|
|
918
|
+
function filterAssets(assets, include) {
|
|
919
|
+
return {
|
|
920
|
+
plugins: include.includes("plugins") ? assets.plugins : {},
|
|
921
|
+
mcp: include.includes("mcp") ? assets.mcp : {},
|
|
922
|
+
skills: include.includes("skills") ? assets.skills : {}
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
async function collectStaticAssets(directory, home, options) {
|
|
926
|
+
const config = await loadProjectConfig(directory);
|
|
927
|
+
const warnings = [...config.warnings];
|
|
928
|
+
const plugins = {};
|
|
929
|
+
for (const ref of config.plugins) {
|
|
930
|
+
const id = ref.target;
|
|
931
|
+
const result = await pluginAsset(id, ref, directory, home);
|
|
932
|
+
if (result.asset !== undefined)
|
|
933
|
+
plugins[id] = result.asset;
|
|
934
|
+
if (result.warning !== undefined)
|
|
935
|
+
warnings.push(result.warning);
|
|
936
|
+
}
|
|
937
|
+
const mcp = {};
|
|
938
|
+
const mcpConfig = options?.includeGlobal === true ? { ...await loadGlobalMcp(home), ...config.mcp } : config.mcp;
|
|
939
|
+
for (const [name, value] of Object.entries(mcpConfig)) {
|
|
940
|
+
const asset = mcpConfigAsset(name, value);
|
|
941
|
+
if (asset !== undefined)
|
|
942
|
+
mcp[name] = asset;
|
|
943
|
+
}
|
|
944
|
+
const include = options?.include ?? ["plugins", "mcp", "skills"];
|
|
945
|
+
return {
|
|
946
|
+
assets: filterAssets({ plugins, mcp, skills: await scanSkills(directory) }, include),
|
|
947
|
+
warnings,
|
|
948
|
+
files: config.files
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
function mergeRuntimeTools(current, previous) {
|
|
952
|
+
if (previous === undefined)
|
|
953
|
+
return current;
|
|
954
|
+
const mcp = {};
|
|
955
|
+
for (const [name, asset] of Object.entries(current.mcp)) {
|
|
956
|
+
const old = previous.assets.mcp[name];
|
|
957
|
+
mcp[name] = old !== undefined && old.configHash === asset.configHash && old.tools !== undefined ? { ...asset, tools: old.tools, toolsSource: old.toolsSource ?? "runtime" } : asset;
|
|
958
|
+
}
|
|
959
|
+
return { ...current, mcp };
|
|
960
|
+
}
|
|
961
|
+
function filterIgnored(drift, patterns) {
|
|
962
|
+
if (patterns.length === 0)
|
|
963
|
+
return [...drift];
|
|
964
|
+
return drift.filter((entry) => {
|
|
965
|
+
const id = entry.asset === "mcp-tool" ? `${entry.parent}:${entry.id}` : entry.id;
|
|
966
|
+
return !ignored(id, patterns) && !ignored(`${entry.asset}:${id}`, patterns);
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
function describeAudit(result, verbose = false) {
|
|
970
|
+
const lines = [];
|
|
971
|
+
if (!result.locked) {
|
|
972
|
+
lines.push("No lockfile found. This is an audit only.");
|
|
973
|
+
} else if (result.ok) {
|
|
974
|
+
lines.push("Integrity OK: no drift from opencode.lock.json.");
|
|
975
|
+
} else {
|
|
976
|
+
const counts = severityCounts(result.drift);
|
|
977
|
+
lines.push(`Drift detected: ${result.drift.length} change(s) — ${counts.high ?? 0} high, ${counts.medium ?? 0} medium, ${counts.low ?? 0} low.`);
|
|
978
|
+
lines.push(formatDrift(result.drift));
|
|
979
|
+
}
|
|
980
|
+
const pluginCount = Object.keys(result.assets.plugins).length;
|
|
981
|
+
const mcpCount = Object.keys(result.assets.mcp).length;
|
|
982
|
+
const toolCount = Object.values(result.assets.mcp).reduce((sum, asset) => sum + Object.keys(asset.tools ?? {}).length, 0);
|
|
983
|
+
const skillCount = Object.keys(result.assets.skills).length;
|
|
984
|
+
lines.push(`Assets: ${pluginCount} plugin(s), ${mcpCount} MCP server(s), ${toolCount} MCP tool(s), ${skillCount} skill(s).`);
|
|
985
|
+
if (verbose) {
|
|
986
|
+
for (const [id, asset] of Object.entries(result.assets.plugins)) {
|
|
987
|
+
lines.push(` plugin ${id}${asset.version === undefined ? "" : `@${asset.version}`} ${asset.files ?? 0} files [${(asset.capabilities ?? []).join(", ") || "no detected capabilities"}]`);
|
|
988
|
+
}
|
|
989
|
+
for (const [id, asset] of Object.entries(result.assets.mcp)) {
|
|
990
|
+
lines.push(` mcp ${id} (${asset.transport}) ${asset.command ?? asset.url ?? ""}`);
|
|
991
|
+
}
|
|
992
|
+
for (const [id, asset] of Object.entries(result.assets.skills)) {
|
|
993
|
+
lines.push(` skill ${id} ${asset.bytes} bytes${(asset.suspicious ?? []).length > 0 ? ` suspicious: ${(asset.suspicious ?? []).join(", ")}` : ""}`);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
for (const warning of result.warnings)
|
|
997
|
+
lines.push(`warning: ${warning}`);
|
|
998
|
+
return lines.join(`
|
|
999
|
+
`);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// src/options.ts
|
|
1003
|
+
import { homedir } from "node:os";
|
|
1004
|
+
import { isAbsolute as isAbsolute3, resolve as resolve3 } from "node:path";
|
|
1005
|
+
var DEFAULTS = {
|
|
1006
|
+
lockfile: "opencode.lock.json",
|
|
1007
|
+
mode: "warn",
|
|
1008
|
+
include: ["plugins", "mcp", "skills"],
|
|
1009
|
+
ignore: [],
|
|
1010
|
+
includeGlobal: false
|
|
1011
|
+
};
|
|
1012
|
+
function record(value) {
|
|
1013
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
1014
|
+
}
|
|
1015
|
+
function stringArray(value) {
|
|
1016
|
+
if (!Array.isArray(value))
|
|
1017
|
+
return [];
|
|
1018
|
+
return value.filter((entry) => typeof entry === "string").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
|
1019
|
+
}
|
|
1020
|
+
function expandPath(value, directory, home = homedir()) {
|
|
1021
|
+
let output = value;
|
|
1022
|
+
if (output === "~")
|
|
1023
|
+
output = home;
|
|
1024
|
+
else if (output.startsWith("~/"))
|
|
1025
|
+
output = resolve3(home, output.slice(2));
|
|
1026
|
+
return isAbsolute3(output) ? resolve3(output) : resolve3(directory, output);
|
|
1027
|
+
}
|
|
1028
|
+
function parseOptions(raw, directory, home = homedir()) {
|
|
1029
|
+
const input = record(raw);
|
|
1030
|
+
const include = stringArray(input.include).filter((entry) => ["plugins", "mcp", "skills"].includes(entry));
|
|
1031
|
+
const lockfile = typeof input.lockfile === "string" && input.lockfile.trim().length > 0 ? expandPath(input.lockfile, directory, home) : resolve3(directory, DEFAULTS.lockfile);
|
|
1032
|
+
const trace = typeof input.trace === "string" && input.trace.trim().length > 0 ? expandPath(input.trace, directory, home) : undefined;
|
|
1033
|
+
return {
|
|
1034
|
+
lockfile,
|
|
1035
|
+
mode: input.mode === "enforce" ? "enforce" : "warn",
|
|
1036
|
+
include: include.length > 0 ? include : DEFAULTS.include,
|
|
1037
|
+
ignore: stringArray(input.ignore),
|
|
1038
|
+
includeGlobal: input.includeGlobal === true,
|
|
1039
|
+
trace
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// src/policy.ts
|
|
1044
|
+
function generatePolicy(assets) {
|
|
1045
|
+
const policies = [];
|
|
1046
|
+
for (const id of Object.keys(assets.mcp).sort()) {
|
|
1047
|
+
policies.push({ action: "mcp.use", resource: id, effect: "allow" });
|
|
1048
|
+
}
|
|
1049
|
+
for (const id of Object.keys(assets.skills).sort()) {
|
|
1050
|
+
policies.push({ action: "skill.use", resource: id, effect: "allow" });
|
|
1051
|
+
}
|
|
1052
|
+
for (const asset of Object.values(assets.plugins)) {
|
|
1053
|
+
const resource = asset.source.startsWith(".") ? asset.source : asset.source.split("@").slice(0, 2).join("@");
|
|
1054
|
+
policies.push({ action: "plugin.use", resource, effect: "allow" });
|
|
1055
|
+
}
|
|
1056
|
+
return { policies };
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// src/sarif.ts
|
|
1060
|
+
var RULES = [
|
|
1061
|
+
"plugin-changed",
|
|
1062
|
+
"plugin-added",
|
|
1063
|
+
"plugin-removed",
|
|
1064
|
+
"mcp-changed",
|
|
1065
|
+
"mcp-added",
|
|
1066
|
+
"mcp-removed",
|
|
1067
|
+
"mcp-tool-changed",
|
|
1068
|
+
"mcp-tool-added",
|
|
1069
|
+
"mcp-tool-removed",
|
|
1070
|
+
"skill-changed",
|
|
1071
|
+
"skill-added",
|
|
1072
|
+
"skill-removed",
|
|
1073
|
+
"lockfile-missing"
|
|
1074
|
+
];
|
|
1075
|
+
function ruleID(entry) {
|
|
1076
|
+
const suffix = entry.asset === "mcp-tool" ? `mcp-tool-${entry.change}` : `${entry.asset}-${entry.change}`;
|
|
1077
|
+
return `integrity/${RULES.includes(suffix) ? suffix : "plugin-changed"}`;
|
|
1078
|
+
}
|
|
1079
|
+
function toSarif(drift, version, lockfile, missingLockfile = false) {
|
|
1080
|
+
return {
|
|
1081
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
1082
|
+
version: "2.1.0",
|
|
1083
|
+
runs: [
|
|
1084
|
+
{
|
|
1085
|
+
tool: {
|
|
1086
|
+
driver: {
|
|
1087
|
+
name: "opencode-integrity",
|
|
1088
|
+
informationUri: "https://github.com/Hamza-Abili/opencode-integrity",
|
|
1089
|
+
version,
|
|
1090
|
+
rules: RULES.map((id) => ({ id: `integrity/${id}` }))
|
|
1091
|
+
}
|
|
1092
|
+
},
|
|
1093
|
+
results: [
|
|
1094
|
+
...missingLockfile ? [
|
|
1095
|
+
{
|
|
1096
|
+
ruleId: "integrity/lockfile-missing",
|
|
1097
|
+
level: "error",
|
|
1098
|
+
message: { text: `no lockfile at ${lockfile}; run integrity lock` },
|
|
1099
|
+
locations: [{ physicalLocation: { artifactLocation: { uri: lockfile } } }]
|
|
1100
|
+
}
|
|
1101
|
+
] : [],
|
|
1102
|
+
...drift.map((entry) => ({
|
|
1103
|
+
ruleId: ruleID(entry),
|
|
1104
|
+
level: entry.severity === "high" ? "error" : entry.severity === "medium" ? "warning" : "note",
|
|
1105
|
+
message: { text: entry.message },
|
|
1106
|
+
locations: [{ physicalLocation: { artifactLocation: { uri: lockfile } } }]
|
|
1107
|
+
}))
|
|
1108
|
+
]
|
|
1109
|
+
}
|
|
1110
|
+
]
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// src/version.ts
|
|
1115
|
+
var VERSION = "0.1.0";
|
|
1116
|
+
|
|
1117
|
+
// src/cli.ts
|
|
1118
|
+
function parseArgs(argv) {
|
|
1119
|
+
const output = {
|
|
1120
|
+
command: "audit",
|
|
1121
|
+
directory: process.cwd(),
|
|
1122
|
+
home: homedir2(),
|
|
1123
|
+
format: "text",
|
|
1124
|
+
strict: false,
|
|
1125
|
+
ignore: [],
|
|
1126
|
+
includeGlobal: false
|
|
1127
|
+
};
|
|
1128
|
+
for (let index = 0;index < argv.length; index += 1) {
|
|
1129
|
+
const arg = argv[index] ?? "";
|
|
1130
|
+
const next = () => {
|
|
1131
|
+
index += 1;
|
|
1132
|
+
return argv[index] ?? "";
|
|
1133
|
+
};
|
|
1134
|
+
if (["audit", "lock", "check", "diff", "policy", "version"].includes(arg)) {
|
|
1135
|
+
output.command = arg;
|
|
1136
|
+
continue;
|
|
1137
|
+
}
|
|
1138
|
+
switch (arg) {
|
|
1139
|
+
case "--directory":
|
|
1140
|
+
case "-C":
|
|
1141
|
+
output.directory = resolve4(next());
|
|
1142
|
+
break;
|
|
1143
|
+
case "--home":
|
|
1144
|
+
output.home = resolve4(next());
|
|
1145
|
+
break;
|
|
1146
|
+
case "--lockfile":
|
|
1147
|
+
output.lockfile = next();
|
|
1148
|
+
break;
|
|
1149
|
+
case "--json":
|
|
1150
|
+
output.format = "json";
|
|
1151
|
+
break;
|
|
1152
|
+
case "--format": {
|
|
1153
|
+
const value = next();
|
|
1154
|
+
output.format = value === "json" || value === "sarif" ? value : "text";
|
|
1155
|
+
break;
|
|
1156
|
+
}
|
|
1157
|
+
case "--strict":
|
|
1158
|
+
output.strict = true;
|
|
1159
|
+
break;
|
|
1160
|
+
case "--ignore":
|
|
1161
|
+
output.ignore.push(next());
|
|
1162
|
+
break;
|
|
1163
|
+
case "--include-global":
|
|
1164
|
+
output.includeGlobal = true;
|
|
1165
|
+
break;
|
|
1166
|
+
case "--version":
|
|
1167
|
+
case "-v":
|
|
1168
|
+
output.command = "version";
|
|
1169
|
+
break;
|
|
1170
|
+
default:
|
|
1171
|
+
break;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
return output;
|
|
1175
|
+
}
|
|
1176
|
+
function usage() {
|
|
1177
|
+
return [
|
|
1178
|
+
"opencode-integrity <audit|lock|check|diff|policy|version> [options]",
|
|
1179
|
+
"",
|
|
1180
|
+
" audit show loaded assets and drift (exit 0)",
|
|
1181
|
+
" lock write opencode.lock.json from the current project",
|
|
1182
|
+
" check compare against the lockfile (exit 1 on drift)",
|
|
1183
|
+
" diff print the drift report (exit 0)",
|
|
1184
|
+
" policy emit OpenCode policy allow statements from the lockfile",
|
|
1185
|
+
"",
|
|
1186
|
+
" --directory, -C <path> project directory (default: cwd)",
|
|
1187
|
+
" --home <path> home directory (default: $HOME)",
|
|
1188
|
+
" --lockfile <path> lockfile path (default: <project>/opencode.lock.json)",
|
|
1189
|
+
" --format text|json|sarif",
|
|
1190
|
+
" --strict audit exits 1 on drift or an unlocked project",
|
|
1191
|
+
" --ignore <pattern> ignore an asset id (repeatable)",
|
|
1192
|
+
" --include-global include global MCP config (runtime also plugins/skills)"
|
|
1193
|
+
].join(`
|
|
1194
|
+
`);
|
|
1195
|
+
}
|
|
1196
|
+
async function main() {
|
|
1197
|
+
const args = parseArgs(process.argv.slice(2));
|
|
1198
|
+
if (args.command === "version") {
|
|
1199
|
+
console.log(`opencode-integrity ${VERSION}`);
|
|
1200
|
+
return 0;
|
|
1201
|
+
}
|
|
1202
|
+
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
1203
|
+
console.log(usage());
|
|
1204
|
+
return 0;
|
|
1205
|
+
}
|
|
1206
|
+
const options = parseOptions({
|
|
1207
|
+
lockfile: args.lockfile,
|
|
1208
|
+
ignore: args.ignore,
|
|
1209
|
+
includeGlobal: args.includeGlobal,
|
|
1210
|
+
mode: "warn"
|
|
1211
|
+
}, args.directory, args.home);
|
|
1212
|
+
const current = await collectStaticAssets(args.directory, args.home, options);
|
|
1213
|
+
const rawPrevious = await readLockfile(options.lockfile);
|
|
1214
|
+
const previous = rawPrevious === undefined ? undefined : { ...rawPrevious, assets: filterAssets(rawPrevious.assets, options.include) };
|
|
1215
|
+
if (args.command === "lock") {
|
|
1216
|
+
const merged = filterAssets(mergeRuntimeTools(current.assets, previous), options.include);
|
|
1217
|
+
const lockfile = createLockfile(merged, undefined, VERSION);
|
|
1218
|
+
await writeLockfile(options.lockfile, lockfile);
|
|
1219
|
+
if (args.format === "json")
|
|
1220
|
+
console.log(JSON.stringify(lockfile, null, 2));
|
|
1221
|
+
else {
|
|
1222
|
+
console.log(`locked ${options.lockfile}`);
|
|
1223
|
+
console.log(`assets: ${Object.keys(merged.plugins).length} plugin(s), ${Object.keys(merged.mcp).length} MCP server(s), ${Object.keys(merged.skills).length} skill(s)`);
|
|
1224
|
+
const missing = Object.entries(merged.mcp).filter(([, asset]) => Object.keys(asset.tools ?? {}).length === 0).map(([name]) => name);
|
|
1225
|
+
if (missing.length > 0) {
|
|
1226
|
+
console.log(`warning: no runtime MCP tool descriptors for ${missing.join(", ")}; run /integrity lock inside OpenCode to pin tool definitions.`);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
return 0;
|
|
1230
|
+
}
|
|
1231
|
+
if (args.command === "policy") {
|
|
1232
|
+
const assets = previous?.assets ?? current.assets;
|
|
1233
|
+
console.log(JSON.stringify(generatePolicy(assets), null, 2));
|
|
1234
|
+
return 0;
|
|
1235
|
+
}
|
|
1236
|
+
const merged = filterAssets(mergeRuntimeTools(current.assets, previous), options.include);
|
|
1237
|
+
const result = audit(previous?.assets, merged, current.warnings, false);
|
|
1238
|
+
const drift = filterIgnored(result.drift, options.ignore);
|
|
1239
|
+
const final = { ...result, drift, ok: drift.length === 0 };
|
|
1240
|
+
if (args.format === "sarif") {
|
|
1241
|
+
const uri = relative3(args.directory, options.lockfile) || options.lockfile;
|
|
1242
|
+
console.log(JSON.stringify(toSarif(drift, VERSION, uri, !final.locked), null, 2));
|
|
1243
|
+
} else if (args.format === "json") {
|
|
1244
|
+
console.log(JSON.stringify(final, null, 2));
|
|
1245
|
+
} else if (args.command === "diff") {
|
|
1246
|
+
console.log(drift.length === 0 ? "no drift" : formatDrift(drift));
|
|
1247
|
+
} else {
|
|
1248
|
+
console.log(describeAudit(final, args.command === "audit"));
|
|
1249
|
+
if (final.locked && !final.ok) {
|
|
1250
|
+
const counts = severityCounts(drift);
|
|
1251
|
+
console.log(`summary: ${counts.high ?? 0} high, ${counts.medium ?? 0} medium, ${counts.low ?? 0} low`);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
if (args.command === "check")
|
|
1255
|
+
return final.ok && final.locked ? 0 : 1;
|
|
1256
|
+
if (args.command === "audit" && args.strict)
|
|
1257
|
+
return final.ok && final.locked ? 0 : 1;
|
|
1258
|
+
return 0;
|
|
1259
|
+
}
|
|
1260
|
+
process.exitCode = await main();
|