@hamzaabili/opencode-integrity 0.0.0-stage → 0.1.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.
- package/CHANGELOG.md +25 -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/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default, plugin, server, VERSION } from "./src/index.ts";
|
package/package.json
CHANGED
|
@@ -1,6 +1,72 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hamzaabili/opencode-integrity",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Lock what your OpenCode agent can load: MCP tool definitions, skills and plugins. Detect drift, tool poisoning and rug pulls at runtime. Zero runtime dependencies, V1+V2.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Hamza-Abili",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"opencode",
|
|
10
|
+
"opencode-plugin",
|
|
11
|
+
"opencode-v1",
|
|
12
|
+
"opencode-v2",
|
|
13
|
+
"mcp",
|
|
14
|
+
"mcp-security",
|
|
15
|
+
"tool-poisoning",
|
|
16
|
+
"rug-pull",
|
|
17
|
+
"supply-chain",
|
|
18
|
+
"integrity",
|
|
19
|
+
"lockfile",
|
|
20
|
+
"skills",
|
|
21
|
+
"security"
|
|
22
|
+
],
|
|
23
|
+
"homepage": "https://github.com/Hamza-Abili/opencode-integrity#readme",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/Hamza-Abili/opencode-integrity.git"
|
|
27
|
+
},
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/Hamza-Abili/opencode-integrity/issues"
|
|
30
|
+
},
|
|
31
|
+
"main": "./index.ts",
|
|
32
|
+
"types": "./src/index.ts",
|
|
33
|
+
"bin": {
|
|
34
|
+
"opencode-integrity": "./dist/cli.mjs"
|
|
35
|
+
},
|
|
36
|
+
"oc-plugin": [
|
|
37
|
+
"server"
|
|
38
|
+
],
|
|
39
|
+
"exports": {
|
|
40
|
+
".": "./index.ts",
|
|
41
|
+
"./package.json": "./package.json"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"src",
|
|
45
|
+
"scripts",
|
|
46
|
+
"index.ts",
|
|
47
|
+
"dist/cli.mjs",
|
|
48
|
+
"README.md",
|
|
49
|
+
"CHANGELOG.md",
|
|
50
|
+
"SECURITY.md",
|
|
51
|
+
"LICENSE"
|
|
52
|
+
],
|
|
53
|
+
"scripts": {
|
|
54
|
+
"typecheck": "tsc --noEmit",
|
|
55
|
+
"lint": "biome check .",
|
|
56
|
+
"format": "biome check --write .",
|
|
57
|
+
"test": "bun test",
|
|
58
|
+
"build": "bun run scripts/build.ts",
|
|
59
|
+
"ci": "bun run lint && bun run typecheck && bun run test && bun run build && bun run smoke",
|
|
60
|
+
"smoke": "bun scripts/smoke.ts",
|
|
61
|
+
"prepack": "bun run build"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@biomejs/biome": "^2.6.6",
|
|
65
|
+
"@types/bun": "^1.4.4",
|
|
66
|
+
"typescript": "^7.0.2"
|
|
67
|
+
},
|
|
68
|
+
"engines": {
|
|
69
|
+
"bun": ">=1.1.0",
|
|
70
|
+
"opencode": ">=1.19.5 <2 || >=2.2.1"
|
|
71
|
+
}
|
|
72
|
+
}
|
package/scripts/build.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
const outdir = join(import.meta.dir, "..", "dist");
|
|
5
|
+
await mkdir(outdir, { recursive: true });
|
|
6
|
+
const result = await Bun.build({
|
|
7
|
+
entrypoints: [join(import.meta.dir, "..", "src", "cli.ts")],
|
|
8
|
+
outdir,
|
|
9
|
+
naming: "cli.mjs",
|
|
10
|
+
target: "node",
|
|
11
|
+
format: "esm",
|
|
12
|
+
minify: false,
|
|
13
|
+
sourcemap: "none",
|
|
14
|
+
});
|
|
15
|
+
if (!result.success) {
|
|
16
|
+
for (const log of result.logs) console.error(log);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
const output = join(outdir, "cli.mjs");
|
|
20
|
+
const code = await readFile(output, "utf8");
|
|
21
|
+
await writeFile(output, code.replace(/^#![^\n]*\n/, "#!/usr/bin/env node\n"), "utf8");
|
|
22
|
+
await chmod(output, 0o755);
|
|
23
|
+
console.log(`built ${output}`);
|
package/scripts/smoke.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const directory = await mkdtemp(join(tmpdir(), "oi-smoke-"));
|
|
6
|
+
const home = join(directory, "home");
|
|
7
|
+
await mkdir(join(directory, ".opencode", "skills", "demo"), { recursive: true });
|
|
8
|
+
await writeFile(
|
|
9
|
+
join(directory, "opencode.json"),
|
|
10
|
+
`${JSON.stringify(
|
|
11
|
+
{
|
|
12
|
+
mcp: {
|
|
13
|
+
demo: { type: "local", command: ["node", "server.mjs"], environment: { TOKEN: "secret" } },
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
null,
|
|
17
|
+
2
|
|
18
|
+
)}\n`
|
|
19
|
+
);
|
|
20
|
+
await writeFile(
|
|
21
|
+
join(directory, ".opencode", "skills", "demo", "SKILL.md"),
|
|
22
|
+
"---\nname: demo\ndescription: demo skill\n---\n# Demo\n"
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const cli = join(import.meta.dir, "..", "src", "cli.ts");
|
|
26
|
+
const run = (args: string[]) =>
|
|
27
|
+
Bun.spawn(["bun", cli, ...args], { stdout: "pipe", stderr: "pipe" });
|
|
28
|
+
|
|
29
|
+
const lock = await run(["lock", "--directory", directory, "--home", home]);
|
|
30
|
+
const lockOut = await new Response(lock.stdout).text();
|
|
31
|
+
if ((await lock.exited) !== 0) throw new Error(`lock failed: ${lockOut}`);
|
|
32
|
+
if (!(await Bun.file(join(directory, "opencode.lock.json")).exists()))
|
|
33
|
+
throw new Error("lockfile missing");
|
|
34
|
+
|
|
35
|
+
const check = await run(["check", "--directory", directory, "--home", home]);
|
|
36
|
+
const checkOut = await new Response(check.stdout).text();
|
|
37
|
+
if ((await check.exited) !== 0) throw new Error(`check failed: ${checkOut}`);
|
|
38
|
+
if (!checkOut.includes("Integrity OK")) throw new Error(`unexpected check output: ${checkOut}`);
|
|
39
|
+
console.log("smoke ok (lock + clean check)");
|
package/src/assets.ts
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import type { Dirent } from "node:fs";
|
|
2
|
+
import { readdir, realpath } from "node:fs/promises";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
+
import type { PluginRef } from "./config.ts";
|
|
6
|
+
import type { McpAsset, McpToolAsset, PluginAsset, SkillAsset } from "./types.ts";
|
|
7
|
+
import {
|
|
8
|
+
detectCapabilities,
|
|
9
|
+
detectSuspicious,
|
|
10
|
+
hashDirectory,
|
|
11
|
+
hashObject,
|
|
12
|
+
hashText,
|
|
13
|
+
isDirectory,
|
|
14
|
+
isInside,
|
|
15
|
+
pathExists,
|
|
16
|
+
readJsonFileCapped,
|
|
17
|
+
readTextFileCapped,
|
|
18
|
+
sha256,
|
|
19
|
+
stableStringify,
|
|
20
|
+
} from "./util.ts";
|
|
21
|
+
|
|
22
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
23
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function secretKey(key: string): boolean {
|
|
27
|
+
return /(token|secret|password|api[-_]?key|authorization|cookie|credential)/i.test(key);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function redactValue(key: string, value: unknown): unknown {
|
|
31
|
+
return secretKey(key) ? "[redacted]" : value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function redactRecord(value: unknown): Record<string, unknown> {
|
|
35
|
+
if (!isObject(value)) return {};
|
|
36
|
+
const output: Record<string, unknown> = {};
|
|
37
|
+
for (const [key, entry] of Object.entries(value)) output[key] = redactValue(key, entry);
|
|
38
|
+
return output;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function redactCommand(command: readonly string[]): string[] {
|
|
42
|
+
return command.map((entry) =>
|
|
43
|
+
entry.replace(/((?:token|secret|password|api[-_]?key)=)[^\s]+/gi, "$1[redacted]")
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sanitizeUrl(value: string): string {
|
|
48
|
+
try {
|
|
49
|
+
return new URL(value).origin;
|
|
50
|
+
} catch {
|
|
51
|
+
return "[invalid-url]";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function packageJsonPath(root: string): string {
|
|
56
|
+
return join(root, "package.json");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function walkUpToPackage(start: string): Promise<string | undefined> {
|
|
60
|
+
let current = resolve(start);
|
|
61
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
62
|
+
if (await pathExists(packageJsonPath(current))) return current;
|
|
63
|
+
const parent = dirname(current);
|
|
64
|
+
if (parent === current) break;
|
|
65
|
+
current = parent;
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Resolve a package or local plugin reference to its installed root. */
|
|
71
|
+
export async function resolvePluginRoot(
|
|
72
|
+
ref: PluginRef,
|
|
73
|
+
directory: string,
|
|
74
|
+
home: string
|
|
75
|
+
): Promise<string | undefined> {
|
|
76
|
+
const target = ref.localPath ?? ref.target;
|
|
77
|
+
if (isAbsolute(target) || target.startsWith(".") || target.startsWith("~/")) {
|
|
78
|
+
const expanded = target.startsWith("~")
|
|
79
|
+
? resolve(home, target.slice(2))
|
|
80
|
+
: resolve(directory, target);
|
|
81
|
+
if (await isDirectory(expanded)) return expanded;
|
|
82
|
+
return await walkUpToPackage(dirname(expanded));
|
|
83
|
+
}
|
|
84
|
+
const direct = join(directory, "node_modules", target);
|
|
85
|
+
if (await isDirectory(direct)) return direct;
|
|
86
|
+
try {
|
|
87
|
+
const require = createRequire(join(directory, "package.json"));
|
|
88
|
+
const entry = require.resolve(target);
|
|
89
|
+
return await walkUpToPackage(dirname(entry));
|
|
90
|
+
} catch {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function entryText(root: string, realRoot: string): Promise<string> {
|
|
96
|
+
const manifest = await readJsonFileCapped<Record<string, unknown>>(packageJsonPath(root));
|
|
97
|
+
const candidates = new Set<string>();
|
|
98
|
+
if (typeof manifest?.main === "string") candidates.add(manifest.main);
|
|
99
|
+
if (typeof manifest?.module === "string") candidates.add(manifest.module);
|
|
100
|
+
const exports = manifest?.exports;
|
|
101
|
+
if (typeof exports === "string") candidates.add(exports);
|
|
102
|
+
else if (isObject(exports)) {
|
|
103
|
+
for (const value of Object.values(exports)) {
|
|
104
|
+
if (typeof value === "string") candidates.add(value);
|
|
105
|
+
else if (isObject(value)) {
|
|
106
|
+
for (const nested of Object.values(value))
|
|
107
|
+
if (typeof nested === "string") candidates.add(nested);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
candidates.add("index.ts");
|
|
112
|
+
candidates.add("index.js");
|
|
113
|
+
for (const candidate of candidates) {
|
|
114
|
+
const path = join(root, candidate);
|
|
115
|
+
if (!isInside(root, path)) continue;
|
|
116
|
+
const real = await realpath(path).catch(() => undefined);
|
|
117
|
+
if (real === undefined || !isInside(realRoot, real)) continue;
|
|
118
|
+
const text = await readTextFileCapped(real);
|
|
119
|
+
if (text !== undefined) return text;
|
|
120
|
+
}
|
|
121
|
+
return "";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function pluginAsset(
|
|
125
|
+
id: string,
|
|
126
|
+
ref: PluginRef,
|
|
127
|
+
directory: string,
|
|
128
|
+
home: string,
|
|
129
|
+
allowOutside = false
|
|
130
|
+
): Promise<{ asset?: PluginAsset; warning?: string }> {
|
|
131
|
+
const root = await resolvePluginRoot(ref, directory, home);
|
|
132
|
+
if (root === undefined) {
|
|
133
|
+
return {
|
|
134
|
+
warning: `${id}: package not installed (${ref.target}); run the package manager first`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const realRoot = await realpath(root).catch(() => root);
|
|
138
|
+
const realDirectory = await realpath(directory).catch(() => directory);
|
|
139
|
+
if (!allowOutside && !isInside(realDirectory, realRoot)) {
|
|
140
|
+
return { warning: `${id}: local plugin resolves outside the project and was ignored` };
|
|
141
|
+
}
|
|
142
|
+
const manifest = await readJsonFileCapped<Record<string, unknown>>(packageJsonPath(realRoot));
|
|
143
|
+
const version =
|
|
144
|
+
ref.version ?? (typeof manifest?.version === "string" ? manifest.version : undefined);
|
|
145
|
+
const content = await hashDirectory(root);
|
|
146
|
+
const text = await entryText(root, realRoot);
|
|
147
|
+
return {
|
|
148
|
+
asset: {
|
|
149
|
+
source: ref.target,
|
|
150
|
+
version,
|
|
151
|
+
packageHash: hashObject(manifest ?? {}),
|
|
152
|
+
contentHash: content.hash,
|
|
153
|
+
files: content.files,
|
|
154
|
+
bytes: content.bytes,
|
|
155
|
+
capabilities: detectCapabilities(text),
|
|
156
|
+
truncated: content.truncated,
|
|
157
|
+
},
|
|
158
|
+
warning: content.truncated ? `${id}: hash truncated at 5000 files / 50 MiB` : undefined,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function skillFiles(root: string): Promise<string[]> {
|
|
163
|
+
const output: string[] = [];
|
|
164
|
+
async function walk(directory: string, depth: number): Promise<void> {
|
|
165
|
+
if (depth > 4) return;
|
|
166
|
+
let entries: Dirent[];
|
|
167
|
+
try {
|
|
168
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
169
|
+
} catch {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
for (const entry of entries) {
|
|
173
|
+
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
|
174
|
+
const path = join(directory, entry.name);
|
|
175
|
+
if (entry.isDirectory()) await walk(path, depth + 1);
|
|
176
|
+
else if (entry.isFile() && entry.name === "SKILL.md") output.push(path);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
await walk(root, 0);
|
|
180
|
+
return output.sort();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function skillID(path: string, content: string): string {
|
|
184
|
+
const match = /^---\s*\n([\s\S]*?)\n---/.exec(content);
|
|
185
|
+
const name = match?.[1]?.match(/^name:\s*["']?([^"'\n]+)["']?/m)?.[1];
|
|
186
|
+
return (name ?? basename(dirname(path))).trim();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function scanSkills(
|
|
190
|
+
directory: string,
|
|
191
|
+
warnings: string[] = []
|
|
192
|
+
): Promise<Record<string, SkillAsset>> {
|
|
193
|
+
const root = join(directory, ".opencode", "skills");
|
|
194
|
+
const output: Record<string, SkillAsset> = {};
|
|
195
|
+
for (const path of await skillFiles(root)) {
|
|
196
|
+
const content = await readTextFileCapped(path);
|
|
197
|
+
if (content === undefined) {
|
|
198
|
+
warnings.push(`${path}: skill too large or unreadable; not hashed`);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const directoryHash = await hashDirectory(dirname(path));
|
|
202
|
+
if (directoryHash.truncated)
|
|
203
|
+
warnings.push(`${path}: skill directory too large to hash completely`);
|
|
204
|
+
output[skillID(path, content)] = {
|
|
205
|
+
path: path.slice(directory.length + 1),
|
|
206
|
+
contentHash: hashText(content),
|
|
207
|
+
directoryHash: directoryHash.hash,
|
|
208
|
+
files: directoryHash.files,
|
|
209
|
+
bytes: directoryHash.bytes,
|
|
210
|
+
truncated: directoryHash.truncated,
|
|
211
|
+
suspicious: detectSuspicious(content),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
return output;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function mcpConfigAsset(_name: string, config: unknown): McpAsset | undefined {
|
|
218
|
+
if (!isObject(config)) return undefined;
|
|
219
|
+
const type =
|
|
220
|
+
typeof config.type === "string"
|
|
221
|
+
? config.type
|
|
222
|
+
: typeof config.url === "string"
|
|
223
|
+
? "remote"
|
|
224
|
+
: "local";
|
|
225
|
+
const transport = type === "remote" ? "remote" : "local";
|
|
226
|
+
if (config.disabled === true) return undefined;
|
|
227
|
+
|
|
228
|
+
const commandValue = config.command;
|
|
229
|
+
const command = Array.isArray(commandValue)
|
|
230
|
+
? commandValue.filter((entry): entry is string => typeof entry === "string")
|
|
231
|
+
: typeof commandValue === "string"
|
|
232
|
+
? commandValue.trim().split(/\s+/)
|
|
233
|
+
: [];
|
|
234
|
+
const environment = redactRecord(config.environment ?? config.env);
|
|
235
|
+
const headers = redactRecord(config.headers);
|
|
236
|
+
const canonical = {
|
|
237
|
+
type,
|
|
238
|
+
command: redactCommand(command),
|
|
239
|
+
cwd: typeof config.cwd === "string" ? config.cwd : undefined,
|
|
240
|
+
url: typeof config.url === "string" ? config.url : undefined,
|
|
241
|
+
environment,
|
|
242
|
+
headers,
|
|
243
|
+
codemode: config.codemode,
|
|
244
|
+
};
|
|
245
|
+
return {
|
|
246
|
+
transport,
|
|
247
|
+
configHash: hashObject(canonical),
|
|
248
|
+
command: command[0] === undefined ? undefined : basename(command[0]),
|
|
249
|
+
url: typeof config.url === "string" ? sanitizeUrl(config.url) : undefined,
|
|
250
|
+
envKeys: Object.keys(environment).sort(),
|
|
251
|
+
toolsSource: "none",
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function toolCanonical(tool: Record<string, unknown>): unknown {
|
|
256
|
+
return {
|
|
257
|
+
name: tool.name,
|
|
258
|
+
description: tool.description,
|
|
259
|
+
input: tool.input,
|
|
260
|
+
output: tool.output,
|
|
261
|
+
annotations: tool.annotations,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function mcpToolAsset(tool: unknown): McpToolAsset | undefined {
|
|
266
|
+
if (!isObject(tool)) return undefined;
|
|
267
|
+
return {
|
|
268
|
+
descriptorHash: hashObject(toolCanonical(tool)),
|
|
269
|
+
descriptionHash: hashText(typeof tool.description === "string" ? tool.description : ""),
|
|
270
|
+
inputHash: hashObject(tool.input ?? {}),
|
|
271
|
+
suspicious: detectSuspicious(
|
|
272
|
+
`${typeof tool.name === "string" ? tool.name : ""}\n${typeof tool.description === "string" ? tool.description : ""}\n${stableStringify(tool.input ?? {})}`
|
|
273
|
+
),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function normalizeSkillPath(directory: string, path: string): string {
|
|
278
|
+
return isInside(directory, path) ? relative(directory, path) : path;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function skillAssetFromInfo(
|
|
282
|
+
skill: unknown,
|
|
283
|
+
directory?: string
|
|
284
|
+
): Promise<[string, SkillAsset] | undefined> {
|
|
285
|
+
if (!isObject(skill) || typeof skill.id !== "string" || typeof skill.content !== "string") {
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
if (typeof skill.path === "string" && skill.path.startsWith("skill://")) return undefined;
|
|
289
|
+
if (Buffer.byteLength(skill.content, "utf8") > 2 * 1024 * 1024) return undefined;
|
|
290
|
+
const absolutePath = typeof skill.path === "string" ? skill.path : skill.id;
|
|
291
|
+
const path = directory === undefined ? absolutePath : normalizeSkillPath(directory, absolutePath);
|
|
292
|
+
const directoryHash = await hashDirectory(dirname(absolutePath));
|
|
293
|
+
return [
|
|
294
|
+
skill.id,
|
|
295
|
+
{
|
|
296
|
+
path,
|
|
297
|
+
contentHash: hashText(skill.content),
|
|
298
|
+
directoryHash: directoryHash.hash,
|
|
299
|
+
files: directoryHash.files,
|
|
300
|
+
bytes: directoryHash.bytes,
|
|
301
|
+
truncated: directoryHash.truncated,
|
|
302
|
+
suspicious: detectSuspicious(skill.content),
|
|
303
|
+
},
|
|
304
|
+
];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function pluginIDFromRuntime(plugin: unknown): string | undefined {
|
|
308
|
+
if (!isObject(plugin)) return undefined;
|
|
309
|
+
if (typeof plugin.id === "string") return plugin.id;
|
|
310
|
+
const source = plugin.source;
|
|
311
|
+
if (!isObject(source)) return undefined;
|
|
312
|
+
if (source.type === "local" && typeof source.path === "string") return source.path;
|
|
313
|
+
if (source.type === "package" && typeof source.target === "string") return source.target;
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function mcpToolsForServer(
|
|
318
|
+
tools: readonly unknown[],
|
|
319
|
+
server: string
|
|
320
|
+
): Record<string, McpToolAsset> {
|
|
321
|
+
const output: Record<string, McpToolAsset> = {};
|
|
322
|
+
const normalized = server.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
323
|
+
for (const value of tools) {
|
|
324
|
+
if (!isObject(value)) continue;
|
|
325
|
+
const id =
|
|
326
|
+
typeof value.id === "string" ? value.id : typeof value.name === "string" ? value.name : "";
|
|
327
|
+
if (id.length === 0) continue;
|
|
328
|
+
const namespace = isObject(value.options) ? value.options.namespace : undefined;
|
|
329
|
+
if (
|
|
330
|
+
namespace !== server &&
|
|
331
|
+
!id.startsWith(`${normalized}_`) &&
|
|
332
|
+
!id.startsWith(`mcp__${normalized}__`)
|
|
333
|
+
) {
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const asset = mcpToolAsset(value);
|
|
337
|
+
if (asset !== undefined) output[id] = asset;
|
|
338
|
+
}
|
|
339
|
+
return output;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function stableAssetID(value: string): string {
|
|
343
|
+
return sha256(value).slice(0, 8);
|
|
344
|
+
}
|
package/src/audit.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { mcpConfigAsset, pluginAsset, scanSkills } from "./assets.ts";
|
|
2
|
+
import { loadGlobalMcp, loadProjectConfig } from "./config.ts";
|
|
3
|
+
import { audit, formatDrift, severityCounts } from "./lockfile.ts";
|
|
4
|
+
import type { AssetKind, Assets, AuditResult, DriftEntry, Lockfile, Options } from "./types.ts";
|
|
5
|
+
import { ignored } from "./util.ts";
|
|
6
|
+
|
|
7
|
+
export interface StaticAudit {
|
|
8
|
+
readonly assets: Assets;
|
|
9
|
+
readonly warnings: readonly string[];
|
|
10
|
+
readonly files: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function filterAssets(assets: Assets, include: readonly AssetKind[]): Assets {
|
|
14
|
+
return {
|
|
15
|
+
plugins: include.includes("plugins") ? assets.plugins : {},
|
|
16
|
+
mcp: include.includes("mcp") ? assets.mcp : {},
|
|
17
|
+
skills: include.includes("skills") ? assets.skills : {},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function collectStaticAssets(
|
|
22
|
+
directory: string,
|
|
23
|
+
home: string,
|
|
24
|
+
options?: Pick<Options, "include" | "includeGlobal">
|
|
25
|
+
): Promise<StaticAudit> {
|
|
26
|
+
const config = await loadProjectConfig(directory);
|
|
27
|
+
const warnings = [...config.warnings];
|
|
28
|
+
const plugins: Record<string, Assets["plugins"][string]> = {};
|
|
29
|
+
for (const ref of config.plugins) {
|
|
30
|
+
const id = ref.target;
|
|
31
|
+
const result = await pluginAsset(id, ref, directory, home);
|
|
32
|
+
if (result.asset !== undefined) plugins[id] = result.asset;
|
|
33
|
+
if (result.warning !== undefined) warnings.push(result.warning);
|
|
34
|
+
}
|
|
35
|
+
const mcp: Record<string, Assets["mcp"][string]> = {};
|
|
36
|
+
const mcpConfig =
|
|
37
|
+
options?.includeGlobal === true
|
|
38
|
+
? { ...(await loadGlobalMcp(home)), ...config.mcp }
|
|
39
|
+
: config.mcp;
|
|
40
|
+
for (const [name, value] of Object.entries(mcpConfig)) {
|
|
41
|
+
const asset = mcpConfigAsset(name, value);
|
|
42
|
+
if (asset !== undefined) mcp[name] = asset;
|
|
43
|
+
}
|
|
44
|
+
const include = options?.include ?? ["plugins", "mcp", "skills"];
|
|
45
|
+
return {
|
|
46
|
+
assets: filterAssets({ plugins, mcp, skills: await scanSkills(directory) }, include),
|
|
47
|
+
warnings,
|
|
48
|
+
files: config.files,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Keep runtime-collected MCP tool descriptors when the server configuration did
|
|
54
|
+
* not change. A CLI lock must not silently erase the tool fingerprints that
|
|
55
|
+
* only OpenCode can observe.
|
|
56
|
+
*/
|
|
57
|
+
export function mergeRuntimeTools(current: Assets, previous?: Lockfile): Assets {
|
|
58
|
+
if (previous === undefined) return current;
|
|
59
|
+
const mcp: Record<string, Assets["mcp"][string]> = {};
|
|
60
|
+
for (const [name, asset] of Object.entries(current.mcp)) {
|
|
61
|
+
const old = previous.assets.mcp[name];
|
|
62
|
+
mcp[name] =
|
|
63
|
+
old !== undefined && old.configHash === asset.configHash && old.tools !== undefined
|
|
64
|
+
? { ...asset, tools: old.tools, toolsSource: old.toolsSource ?? "runtime" }
|
|
65
|
+
: asset;
|
|
66
|
+
}
|
|
67
|
+
return { ...current, mcp };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function filterIgnored(
|
|
71
|
+
drift: readonly DriftEntry[],
|
|
72
|
+
patterns: readonly string[]
|
|
73
|
+
): DriftEntry[] {
|
|
74
|
+
if (patterns.length === 0) return [...drift];
|
|
75
|
+
return drift.filter((entry) => {
|
|
76
|
+
const id = entry.asset === "mcp-tool" ? `${entry.parent}:${entry.id}` : entry.id;
|
|
77
|
+
return !ignored(id, patterns) && !ignored(`${entry.asset}:${id}`, patterns);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function buildAudit(
|
|
82
|
+
current: Assets,
|
|
83
|
+
previous: Lockfile | undefined,
|
|
84
|
+
warnings: readonly string[]
|
|
85
|
+
): AuditResult {
|
|
86
|
+
return audit(previous?.assets, current, warnings);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function describeAudit(result: AuditResult, verbose = false): string {
|
|
90
|
+
const lines: string[] = [];
|
|
91
|
+
if (!result.locked) {
|
|
92
|
+
lines.push("No lockfile found. This is an audit only.");
|
|
93
|
+
} else if (result.ok) {
|
|
94
|
+
lines.push("Integrity OK: no drift from opencode.lock.json.");
|
|
95
|
+
} else {
|
|
96
|
+
const counts = severityCounts(result.drift);
|
|
97
|
+
lines.push(
|
|
98
|
+
`Drift detected: ${result.drift.length} change(s) — ${counts.high ?? 0} high, ${counts.medium ?? 0} medium, ${counts.low ?? 0} low.`
|
|
99
|
+
);
|
|
100
|
+
lines.push(formatDrift(result.drift));
|
|
101
|
+
}
|
|
102
|
+
const pluginCount = Object.keys(result.assets.plugins).length;
|
|
103
|
+
const mcpCount = Object.keys(result.assets.mcp).length;
|
|
104
|
+
const toolCount = Object.values(result.assets.mcp).reduce(
|
|
105
|
+
(sum, asset) => sum + Object.keys(asset.tools ?? {}).length,
|
|
106
|
+
0
|
|
107
|
+
);
|
|
108
|
+
const skillCount = Object.keys(result.assets.skills).length;
|
|
109
|
+
lines.push(
|
|
110
|
+
`Assets: ${pluginCount} plugin(s), ${mcpCount} MCP server(s), ${toolCount} MCP tool(s), ${skillCount} skill(s).`
|
|
111
|
+
);
|
|
112
|
+
if (verbose) {
|
|
113
|
+
for (const [id, asset] of Object.entries(result.assets.plugins)) {
|
|
114
|
+
lines.push(
|
|
115
|
+
` plugin ${id}${asset.version === undefined ? "" : `@${asset.version}`} ${asset.files ?? 0} files [${(asset.capabilities ?? []).join(", ") || "no detected capabilities"}]`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
for (const [id, asset] of Object.entries(result.assets.mcp)) {
|
|
119
|
+
lines.push(` mcp ${id} (${asset.transport}) ${asset.command ?? asset.url ?? ""}`);
|
|
120
|
+
}
|
|
121
|
+
for (const [id, asset] of Object.entries(result.assets.skills)) {
|
|
122
|
+
lines.push(
|
|
123
|
+
` skill ${id} ${asset.bytes} bytes${(asset.suspicious ?? []).length > 0 ? ` suspicious: ${(asset.suspicious ?? []).join(", ")}` : ""}`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const warning of result.warnings) lines.push(`warning: ${warning}`);
|
|
128
|
+
return lines.join("\n");
|
|
129
|
+
}
|