@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/src/cli.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { relative, resolve } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
collectStaticAssets,
|
|
6
|
+
describeAudit,
|
|
7
|
+
filterAssets,
|
|
8
|
+
filterIgnored,
|
|
9
|
+
mergeRuntimeTools,
|
|
10
|
+
} from "./audit.ts";
|
|
11
|
+
import {
|
|
12
|
+
audit,
|
|
13
|
+
createLockfile,
|
|
14
|
+
formatDrift,
|
|
15
|
+
readLockfile,
|
|
16
|
+
severityCounts,
|
|
17
|
+
writeLockfile,
|
|
18
|
+
} from "./lockfile.ts";
|
|
19
|
+
import { parseOptions } from "./options.ts";
|
|
20
|
+
import { generatePolicy } from "./policy.ts";
|
|
21
|
+
import { toSarif } from "./sarif.ts";
|
|
22
|
+
import { VERSION } from "./version.ts";
|
|
23
|
+
|
|
24
|
+
type Command = "audit" | "lock" | "check" | "diff" | "policy" | "version";
|
|
25
|
+
|
|
26
|
+
interface Args {
|
|
27
|
+
command: Command;
|
|
28
|
+
directory: string;
|
|
29
|
+
home: string;
|
|
30
|
+
lockfile?: string;
|
|
31
|
+
format: "text" | "json" | "sarif";
|
|
32
|
+
strict: boolean;
|
|
33
|
+
ignore: string[];
|
|
34
|
+
includeGlobal: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseArgs(argv: readonly string[]): Args {
|
|
38
|
+
const output: Args = {
|
|
39
|
+
command: "audit",
|
|
40
|
+
directory: process.cwd(),
|
|
41
|
+
home: homedir(),
|
|
42
|
+
format: "text",
|
|
43
|
+
strict: false,
|
|
44
|
+
ignore: [],
|
|
45
|
+
includeGlobal: false,
|
|
46
|
+
};
|
|
47
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
48
|
+
const arg = argv[index] ?? "";
|
|
49
|
+
const next = (): string => {
|
|
50
|
+
index += 1;
|
|
51
|
+
return argv[index] ?? "";
|
|
52
|
+
};
|
|
53
|
+
if (["audit", "lock", "check", "diff", "policy", "version"].includes(arg)) {
|
|
54
|
+
output.command = arg as Command;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
switch (arg) {
|
|
58
|
+
case "--directory":
|
|
59
|
+
case "-C":
|
|
60
|
+
output.directory = resolve(next());
|
|
61
|
+
break;
|
|
62
|
+
case "--home":
|
|
63
|
+
output.home = resolve(next());
|
|
64
|
+
break;
|
|
65
|
+
case "--lockfile":
|
|
66
|
+
output.lockfile = next();
|
|
67
|
+
break;
|
|
68
|
+
case "--json":
|
|
69
|
+
output.format = "json";
|
|
70
|
+
break;
|
|
71
|
+
case "--format": {
|
|
72
|
+
const value = next();
|
|
73
|
+
output.format = value === "json" || value === "sarif" ? value : "text";
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
case "--strict":
|
|
77
|
+
output.strict = true;
|
|
78
|
+
break;
|
|
79
|
+
case "--ignore":
|
|
80
|
+
output.ignore.push(next());
|
|
81
|
+
break;
|
|
82
|
+
case "--include-global":
|
|
83
|
+
output.includeGlobal = true;
|
|
84
|
+
break;
|
|
85
|
+
case "--version":
|
|
86
|
+
case "-v":
|
|
87
|
+
output.command = "version";
|
|
88
|
+
break;
|
|
89
|
+
default:
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return output;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function usage(): string {
|
|
97
|
+
return [
|
|
98
|
+
"opencode-integrity <audit|lock|check|diff|policy|version> [options]",
|
|
99
|
+
"",
|
|
100
|
+
" audit show loaded assets and drift (exit 0)",
|
|
101
|
+
" lock write opencode.lock.json from the current project",
|
|
102
|
+
" check compare against the lockfile (exit 1 on drift)",
|
|
103
|
+
" diff print the drift report (exit 0)",
|
|
104
|
+
" policy emit OpenCode policy allow statements from the lockfile",
|
|
105
|
+
"",
|
|
106
|
+
" --directory, -C <path> project directory (default: cwd)",
|
|
107
|
+
" --home <path> home directory (default: $HOME)",
|
|
108
|
+
" --lockfile <path> lockfile path (default: <project>/opencode.lock.json)",
|
|
109
|
+
" --format text|json|sarif",
|
|
110
|
+
" --strict audit exits 1 on drift or an unlocked project",
|
|
111
|
+
" --ignore <pattern> ignore an asset id (repeatable)",
|
|
112
|
+
" --include-global include global MCP config (runtime also plugins/skills)",
|
|
113
|
+
].join("\n");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function main(): Promise<number> {
|
|
117
|
+
const args = parseArgs(process.argv.slice(2));
|
|
118
|
+
if (args.command === "version") {
|
|
119
|
+
console.log(`opencode-integrity ${VERSION}`);
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
123
|
+
console.log(usage());
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const options = parseOptions(
|
|
128
|
+
{
|
|
129
|
+
lockfile: args.lockfile,
|
|
130
|
+
ignore: args.ignore,
|
|
131
|
+
includeGlobal: args.includeGlobal,
|
|
132
|
+
mode: "warn",
|
|
133
|
+
},
|
|
134
|
+
args.directory,
|
|
135
|
+
args.home
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const current = await collectStaticAssets(args.directory, args.home, options);
|
|
139
|
+
const rawPrevious = await readLockfile(options.lockfile);
|
|
140
|
+
const previous =
|
|
141
|
+
rawPrevious === undefined
|
|
142
|
+
? undefined
|
|
143
|
+
: { ...rawPrevious, assets: filterAssets(rawPrevious.assets, options.include) };
|
|
144
|
+
|
|
145
|
+
if (args.command === "lock") {
|
|
146
|
+
const merged = filterAssets(mergeRuntimeTools(current.assets, previous), options.include);
|
|
147
|
+
const lockfile = createLockfile(merged, undefined, VERSION);
|
|
148
|
+
await writeLockfile(options.lockfile, lockfile);
|
|
149
|
+
if (args.format === "json") console.log(JSON.stringify(lockfile, null, 2));
|
|
150
|
+
else {
|
|
151
|
+
console.log(`locked ${options.lockfile}`);
|
|
152
|
+
console.log(
|
|
153
|
+
`assets: ${Object.keys(merged.plugins).length} plugin(s), ${Object.keys(merged.mcp).length} MCP server(s), ${Object.keys(merged.skills).length} skill(s)`
|
|
154
|
+
);
|
|
155
|
+
const missing = Object.entries(merged.mcp)
|
|
156
|
+
.filter(([, asset]) => Object.keys(asset.tools ?? {}).length === 0)
|
|
157
|
+
.map(([name]) => name);
|
|
158
|
+
if (missing.length > 0) {
|
|
159
|
+
console.log(
|
|
160
|
+
`warning: no runtime MCP tool descriptors for ${missing.join(", ")}; run /integrity lock inside OpenCode to pin tool definitions.`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (args.command === "policy") {
|
|
168
|
+
const assets = previous?.assets ?? current.assets;
|
|
169
|
+
console.log(JSON.stringify(generatePolicy(assets), null, 2));
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const merged = filterAssets(mergeRuntimeTools(current.assets, previous), options.include);
|
|
174
|
+
const result = audit(previous?.assets, merged, current.warnings, false);
|
|
175
|
+
const drift = filterIgnored(result.drift, options.ignore);
|
|
176
|
+
const final = { ...result, drift, ok: drift.length === 0 };
|
|
177
|
+
|
|
178
|
+
if (args.format === "sarif") {
|
|
179
|
+
const uri = relative(args.directory, options.lockfile) || options.lockfile;
|
|
180
|
+
console.log(JSON.stringify(toSarif(drift, VERSION, uri, !final.locked), null, 2));
|
|
181
|
+
} else if (args.format === "json") {
|
|
182
|
+
console.log(JSON.stringify(final, null, 2));
|
|
183
|
+
} else if (args.command === "diff") {
|
|
184
|
+
console.log(drift.length === 0 ? "no drift" : formatDrift(drift));
|
|
185
|
+
} else {
|
|
186
|
+
console.log(describeAudit(final, args.command === "audit"));
|
|
187
|
+
if (final.locked && !final.ok) {
|
|
188
|
+
const counts = severityCounts(drift);
|
|
189
|
+
console.log(
|
|
190
|
+
`summary: ${counts.high ?? 0} high, ${counts.medium ?? 0} medium, ${counts.low ?? 0} low`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (args.command === "check") return final.ok && final.locked ? 0 : 1;
|
|
196
|
+
if (args.command === "audit" && args.strict) return final.ok && final.locked ? 0 : 1;
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
process.exitCode = await main();
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { isDirectory, pathExists, readJsoncFile } from "./util.ts";
|
|
3
|
+
|
|
4
|
+
export interface PluginRef {
|
|
5
|
+
readonly target: string;
|
|
6
|
+
readonly version?: string;
|
|
7
|
+
readonly localPath?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ProjectConfig {
|
|
11
|
+
readonly files: readonly string[];
|
|
12
|
+
readonly plugins: readonly PluginRef[];
|
|
13
|
+
readonly mcp: Readonly<Record<string, unknown>>;
|
|
14
|
+
readonly warnings: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
18
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function pluginRef(value: unknown): PluginRef | undefined {
|
|
22
|
+
if (typeof value === "string") {
|
|
23
|
+
return value.trim().length > 0 ? { target: value.trim() } : undefined;
|
|
24
|
+
}
|
|
25
|
+
if (Array.isArray(value) && typeof value[0] === "string") {
|
|
26
|
+
return { target: value[0] };
|
|
27
|
+
}
|
|
28
|
+
if (isObject(value) && typeof value.package === "string") {
|
|
29
|
+
return { target: value.package };
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function configPaths(directory: string): string[] {
|
|
35
|
+
return [
|
|
36
|
+
join(directory, "opencode.json"),
|
|
37
|
+
join(directory, "opencode.jsonc"),
|
|
38
|
+
join(directory, ".opencode", "opencode.json"),
|
|
39
|
+
join(directory, ".opencode", "opencode.jsonc"),
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function mcpFromConfig(data: Record<string, unknown>): Record<string, unknown> {
|
|
44
|
+
const output: Record<string, unknown> = {};
|
|
45
|
+
if (!isObject(data.mcp)) return output;
|
|
46
|
+
if (isObject(data.mcp.servers)) Object.assign(output, data.mcp.servers);
|
|
47
|
+
for (const [name, value] of Object.entries(data.mcp)) {
|
|
48
|
+
if (name === "servers" || name === "timeout") continue;
|
|
49
|
+
if (isObject(value)) output[name] = value;
|
|
50
|
+
}
|
|
51
|
+
return output;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Global MCP servers, only used when `includeGlobal` is enabled. */
|
|
55
|
+
export async function loadGlobalMcp(home: string): Promise<Record<string, unknown>> {
|
|
56
|
+
const output: Record<string, unknown> = {};
|
|
57
|
+
for (const path of [
|
|
58
|
+
join(home, ".config", "opencode", "opencode.json"),
|
|
59
|
+
join(home, ".config", "opencode", "opencode.jsonc"),
|
|
60
|
+
]) {
|
|
61
|
+
if (!(await pathExists(path))) continue;
|
|
62
|
+
const data = await readJsoncFile(path);
|
|
63
|
+
if (isObject(data)) Object.assign(output, mcpFromConfig(data));
|
|
64
|
+
}
|
|
65
|
+
return output;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Read the project-scoped OpenCode configuration. Global config is intentionally excluded. */
|
|
69
|
+
export async function loadProjectConfig(directory: string): Promise<ProjectConfig> {
|
|
70
|
+
const files: string[] = [];
|
|
71
|
+
const plugins: PluginRef[] = [];
|
|
72
|
+
const mcp: Record<string, unknown> = {};
|
|
73
|
+
const warnings: string[] = [];
|
|
74
|
+
|
|
75
|
+
for (const path of configPaths(directory)) {
|
|
76
|
+
if (!(await pathExists(path))) continue;
|
|
77
|
+
const data = await readJsoncFile(path);
|
|
78
|
+
if (!isObject(data)) {
|
|
79
|
+
warnings.push(`${path}: unreadable or invalid JSONC`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
files.push(path);
|
|
83
|
+
const values = [
|
|
84
|
+
...(Array.isArray(data.plugins) ? data.plugins : []),
|
|
85
|
+
...(Array.isArray(data.plugin) ? data.plugin : []),
|
|
86
|
+
];
|
|
87
|
+
for (const value of values) {
|
|
88
|
+
const ref = pluginRef(value);
|
|
89
|
+
if (ref !== undefined) plugins.push(ref);
|
|
90
|
+
}
|
|
91
|
+
Object.assign(mcp, mcpFromConfig(data));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// V2 loads direct plugin files/directories under .opencode/plugins.
|
|
95
|
+
const pluginDirectory = join(directory, ".opencode", "plugins");
|
|
96
|
+
if (await isDirectory(pluginDirectory)) {
|
|
97
|
+
const { readdir } = await import("node:fs/promises");
|
|
98
|
+
for (const entry of await readdir(pluginDirectory, { withFileTypes: true })) {
|
|
99
|
+
if (entry.isDirectory())
|
|
100
|
+
plugins.push({
|
|
101
|
+
target: join(pluginDirectory, entry.name),
|
|
102
|
+
localPath: join(pluginDirectory, entry.name),
|
|
103
|
+
});
|
|
104
|
+
else if (entry.isFile() && /\.(ts|js|mjs|cjs)$/.test(entry.name)) {
|
|
105
|
+
plugins.push({
|
|
106
|
+
target: join(pluginDirectory, entry.name),
|
|
107
|
+
localPath: join(pluginDirectory, entry.name),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { files, plugins: dedupe(plugins), mcp, warnings };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function dedupe(values: readonly PluginRef[]): PluginRef[] {
|
|
117
|
+
const seen = new Set<string>();
|
|
118
|
+
return values.filter((value) => {
|
|
119
|
+
if (seen.has(value.target)) return false;
|
|
120
|
+
seen.add(value.target);
|
|
121
|
+
return true;
|
|
122
|
+
});
|
|
123
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode-integrity — dual OpenCode entry point.
|
|
3
|
+
*
|
|
4
|
+
* V2 calls `setup(ctx)` and inspects the live plugin/MCP/skill/tool surface.
|
|
5
|
+
* V1 calls `server(input, options)` and audits the project-scoped files only.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { server } from "./v1.ts";
|
|
9
|
+
import { setup as setupV2 } from "./v2.ts";
|
|
10
|
+
|
|
11
|
+
export const plugin = {
|
|
12
|
+
id: "opencode-integrity",
|
|
13
|
+
setup: setupV2,
|
|
14
|
+
server,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default plugin;
|
|
18
|
+
export { VERSION } from "./version.ts";
|
|
19
|
+
export { server };
|
package/src/lockfile.ts
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import type { Assets, AuditResult, DriftEntry, Lockfile } from "./types.ts";
|
|
2
|
+
import { SCHEMA_VERSION } from "./types.ts";
|
|
3
|
+
import { readJsonFile, stableStringify, writeFileAtomic } from "./util.ts";
|
|
4
|
+
|
|
5
|
+
function sortRecord<T>(value: Readonly<Record<string, T>>): Record<string, T> {
|
|
6
|
+
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function sortAssets(assets: Assets): Assets {
|
|
10
|
+
return {
|
|
11
|
+
plugins: sortRecord(assets.plugins),
|
|
12
|
+
mcp: Object.fromEntries(
|
|
13
|
+
Object.entries(assets.mcp)
|
|
14
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
15
|
+
.map(([name, asset]) => [
|
|
16
|
+
name,
|
|
17
|
+
{ ...asset, tools: asset.tools === undefined ? undefined : sortRecord(asset.tools) },
|
|
18
|
+
])
|
|
19
|
+
),
|
|
20
|
+
skills: sortRecord(assets.skills),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createLockfile(assets: Assets, opencode?: string, version = "0.1.0"): Lockfile {
|
|
25
|
+
return {
|
|
26
|
+
schemaVersion: SCHEMA_VERSION,
|
|
27
|
+
generatedBy: `opencode-integrity@${version}`,
|
|
28
|
+
opencode,
|
|
29
|
+
assets: sortAssets(assets),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function readLockfile(path: string): Promise<Lockfile | undefined> {
|
|
34
|
+
const raw = await readJsonFile<unknown>(path);
|
|
35
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined;
|
|
36
|
+
const record = raw as Record<string, unknown>;
|
|
37
|
+
if (record.schemaVersion !== SCHEMA_VERSION || typeof record.assets !== "object")
|
|
38
|
+
return undefined;
|
|
39
|
+
const assets = record.assets as Record<string, unknown>;
|
|
40
|
+
return {
|
|
41
|
+
schemaVersion: SCHEMA_VERSION,
|
|
42
|
+
generatedBy: typeof record.generatedBy === "string" ? record.generatedBy : "unknown",
|
|
43
|
+
opencode: typeof record.opencode === "string" ? record.opencode : undefined,
|
|
44
|
+
assets: {
|
|
45
|
+
plugins: isRecord(assets.plugins) ? (assets.plugins as Assets["plugins"]) : {},
|
|
46
|
+
mcp: isRecord(assets.mcp) ? (assets.mcp as Assets["mcp"]) : {},
|
|
47
|
+
skills: isRecord(assets.skills) ? (assets.skills as Assets["skills"]) : {},
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function writeLockfile(path: string, lockfile: Lockfile): Promise<void> {
|
|
53
|
+
const canonical = JSON.parse(stableStringify(lockfile)) as Lockfile;
|
|
54
|
+
await writeFileAtomic(path, `${JSON.stringify(canonical, null, 2)}\n`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
58
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function diffAssets(locked: Assets, current: Assets, compareTools = true): DriftEntry[] {
|
|
62
|
+
const drift: DriftEntry[] = [];
|
|
63
|
+
|
|
64
|
+
for (const [id, asset] of Object.entries(current.plugins)) {
|
|
65
|
+
const before = locked.plugins[id];
|
|
66
|
+
if (before === undefined) {
|
|
67
|
+
drift.push({
|
|
68
|
+
asset: "plugin",
|
|
69
|
+
id,
|
|
70
|
+
change: "added",
|
|
71
|
+
severity: "high",
|
|
72
|
+
message: `new plugin ${id}`,
|
|
73
|
+
});
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (before.truncated === true || asset.truncated === true) {
|
|
77
|
+
drift.push({
|
|
78
|
+
asset: "plugin",
|
|
79
|
+
id,
|
|
80
|
+
change: "changed",
|
|
81
|
+
field: "hash incomplete",
|
|
82
|
+
severity: "high",
|
|
83
|
+
message: `plugin ${id}: package too large to hash completely; reduce it or inspect manually`,
|
|
84
|
+
});
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (before.contentHash !== asset.contentHash || before.packageHash !== asset.packageHash) {
|
|
88
|
+
const fields: string[] = [];
|
|
89
|
+
if (before.version !== asset.version)
|
|
90
|
+
fields.push(`version ${before.version ?? "?"} -> ${asset.version ?? "?"}`);
|
|
91
|
+
if (before.contentHash !== asset.contentHash) fields.push("package content changed");
|
|
92
|
+
drift.push({
|
|
93
|
+
asset: "plugin",
|
|
94
|
+
id,
|
|
95
|
+
change: "changed",
|
|
96
|
+
field: fields.join(", "),
|
|
97
|
+
severity: "high",
|
|
98
|
+
message: `plugin ${id}: ${fields.join(", ") || "content changed"}`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const added = (asset.capabilities ?? []).filter(
|
|
102
|
+
(capability) => !(before.capabilities ?? []).includes(capability)
|
|
103
|
+
);
|
|
104
|
+
if (added.length > 0) {
|
|
105
|
+
drift.push({
|
|
106
|
+
asset: "plugin",
|
|
107
|
+
id,
|
|
108
|
+
change: "changed",
|
|
109
|
+
field: `capabilities +${added.join(",")}`,
|
|
110
|
+
severity: "high",
|
|
111
|
+
message: `plugin ${id}: new capabilities ${added.join(", ")}`,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
for (const id of Object.keys(locked.plugins)) {
|
|
116
|
+
if (current.plugins[id] === undefined) {
|
|
117
|
+
drift.push({
|
|
118
|
+
asset: "plugin",
|
|
119
|
+
id,
|
|
120
|
+
change: "removed",
|
|
121
|
+
severity: "low",
|
|
122
|
+
message: `plugin ${id} removed`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const [id, asset] of Object.entries(current.mcp)) {
|
|
128
|
+
const before = locked.mcp[id];
|
|
129
|
+
if (before === undefined) {
|
|
130
|
+
drift.push({
|
|
131
|
+
asset: "mcp",
|
|
132
|
+
id,
|
|
133
|
+
change: "added",
|
|
134
|
+
severity: "high",
|
|
135
|
+
message: `new MCP server ${id}`,
|
|
136
|
+
});
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (before.configHash !== asset.configHash) {
|
|
140
|
+
drift.push({
|
|
141
|
+
asset: "mcp",
|
|
142
|
+
id,
|
|
143
|
+
change: "changed",
|
|
144
|
+
field: "config",
|
|
145
|
+
severity: "medium",
|
|
146
|
+
message: `MCP server ${id}: command, URL or environment changed`,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
if (!compareTools) continue;
|
|
150
|
+
const beforeTools = before.tools ?? {};
|
|
151
|
+
const currentTools = asset.tools ?? {};
|
|
152
|
+
for (const [tool, toolAsset] of Object.entries(currentTools)) {
|
|
153
|
+
const previous = beforeTools[tool];
|
|
154
|
+
if (previous === undefined) {
|
|
155
|
+
drift.push({
|
|
156
|
+
asset: "mcp-tool",
|
|
157
|
+
id: tool,
|
|
158
|
+
parent: id,
|
|
159
|
+
change: "added",
|
|
160
|
+
severity: "high",
|
|
161
|
+
message: `MCP server ${id}: new tool ${tool}${(toolAsset.suspicious ?? []).length > 0 ? ` [suspicious: ${(toolAsset.suspicious ?? []).join(", ")}]` : ""}`,
|
|
162
|
+
});
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const fields: string[] = [];
|
|
166
|
+
if (previous.descriptionHash !== toolAsset.descriptionHash) fields.push("description");
|
|
167
|
+
if (previous.inputHash !== toolAsset.inputHash) fields.push("input schema");
|
|
168
|
+
if (fields.length > 0 || previous.descriptorHash !== toolAsset.descriptorHash) {
|
|
169
|
+
drift.push({
|
|
170
|
+
asset: "mcp-tool",
|
|
171
|
+
id: tool,
|
|
172
|
+
parent: id,
|
|
173
|
+
change: "changed",
|
|
174
|
+
field: fields.join(", ") || "descriptor",
|
|
175
|
+
severity: "high",
|
|
176
|
+
message: `MCP server ${id}: tool ${tool} changed (${fields.join(", ") || "descriptor"})${(toolAsset.suspicious ?? []).length > 0 ? ` [suspicious: ${(toolAsset.suspicious ?? []).join(", ")}]` : ""}`,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
for (const tool of Object.keys(beforeTools)) {
|
|
181
|
+
if (currentTools[tool] === undefined) {
|
|
182
|
+
drift.push({
|
|
183
|
+
asset: "mcp-tool",
|
|
184
|
+
id: tool,
|
|
185
|
+
parent: id,
|
|
186
|
+
change: "removed",
|
|
187
|
+
severity: "medium",
|
|
188
|
+
message: `MCP server ${id}: tool ${tool} removed`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
for (const id of Object.keys(locked.mcp)) {
|
|
194
|
+
if (current.mcp[id] === undefined) {
|
|
195
|
+
drift.push({
|
|
196
|
+
asset: "mcp",
|
|
197
|
+
id,
|
|
198
|
+
change: "removed",
|
|
199
|
+
severity: "medium",
|
|
200
|
+
message: `MCP server ${id} removed`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
for (const [id, asset] of Object.entries(current.skills)) {
|
|
206
|
+
const before = locked.skills[id];
|
|
207
|
+
if (before === undefined) {
|
|
208
|
+
drift.push({
|
|
209
|
+
asset: "skill",
|
|
210
|
+
id,
|
|
211
|
+
change: "added",
|
|
212
|
+
severity: "high",
|
|
213
|
+
message: `new skill ${id}`,
|
|
214
|
+
});
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (before.truncated === true || asset.truncated === true) {
|
|
218
|
+
drift.push({
|
|
219
|
+
asset: "skill",
|
|
220
|
+
id,
|
|
221
|
+
change: "changed",
|
|
222
|
+
field: "hash incomplete",
|
|
223
|
+
severity: "high",
|
|
224
|
+
message: `skill ${id}: directory too large to hash completely; inspect manually`,
|
|
225
|
+
});
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (
|
|
229
|
+
before.contentHash !== asset.contentHash ||
|
|
230
|
+
before.path !== asset.path ||
|
|
231
|
+
before.directoryHash !== asset.directoryHash
|
|
232
|
+
) {
|
|
233
|
+
const field =
|
|
234
|
+
before.contentHash !== asset.contentHash
|
|
235
|
+
? "content"
|
|
236
|
+
: before.path !== asset.path
|
|
237
|
+
? "path"
|
|
238
|
+
: "files";
|
|
239
|
+
drift.push({
|
|
240
|
+
asset: "skill",
|
|
241
|
+
id,
|
|
242
|
+
change: "changed",
|
|
243
|
+
field,
|
|
244
|
+
severity: "high",
|
|
245
|
+
message: `skill ${id}: ${field} changed${(asset.suspicious ?? []).length > 0 ? ` [suspicious: ${(asset.suspicious ?? []).join(", ")}]` : ""}`,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
for (const id of Object.keys(locked.skills)) {
|
|
250
|
+
if (current.skills[id] === undefined) {
|
|
251
|
+
drift.push({
|
|
252
|
+
asset: "skill",
|
|
253
|
+
id,
|
|
254
|
+
change: "removed",
|
|
255
|
+
severity: "low",
|
|
256
|
+
message: `skill ${id} removed`,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return drift.sort((a, b) => a.asset.localeCompare(b.asset) || a.id.localeCompare(b.id));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function audit(
|
|
265
|
+
locked: Assets | undefined,
|
|
266
|
+
current: Assets,
|
|
267
|
+
warnings: readonly string[],
|
|
268
|
+
compareTools = true
|
|
269
|
+
): AuditResult {
|
|
270
|
+
if (locked === undefined) {
|
|
271
|
+
return { locked: false, ok: true, drift: [], warnings, assets: current };
|
|
272
|
+
}
|
|
273
|
+
const drift = diffAssets(locked, current, compareTools);
|
|
274
|
+
return {
|
|
275
|
+
locked: true,
|
|
276
|
+
ok: drift.length === 0,
|
|
277
|
+
drift,
|
|
278
|
+
warnings,
|
|
279
|
+
assets: current,
|
|
280
|
+
lockedAssets: locked,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function formatDrift(drift: readonly DriftEntry[]): string {
|
|
285
|
+
if (drift.length === 0) return "no drift";
|
|
286
|
+
return drift
|
|
287
|
+
.map((entry) => {
|
|
288
|
+
const icon = entry.change === "added" ? "+" : entry.change === "removed" ? "-" : "~";
|
|
289
|
+
return `${icon} [${entry.severity}] ${entry.message}`;
|
|
290
|
+
})
|
|
291
|
+
.join("\n");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function severityCounts(drift: readonly DriftEntry[]): Record<string, number> {
|
|
295
|
+
const output: Record<string, number> = { high: 0, medium: 0, low: 0 };
|
|
296
|
+
for (const entry of drift) output[entry.severity] = (output[entry.severity] ?? 0) + 1;
|
|
297
|
+
return output;
|
|
298
|
+
}
|
package/src/options.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { isAbsolute, resolve } from "node:path";
|
|
3
|
+
import type { AssetKind, Options } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
const DEFAULTS = {
|
|
6
|
+
lockfile: "opencode.lock.json",
|
|
7
|
+
mode: "warn" as const,
|
|
8
|
+
include: ["plugins", "mcp", "skills"] as readonly AssetKind[],
|
|
9
|
+
ignore: [] as readonly string[],
|
|
10
|
+
includeGlobal: false,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function record(value: unknown): Record<string, unknown> {
|
|
14
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
15
|
+
? (value as Record<string, unknown>)
|
|
16
|
+
: {};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function stringArray(value: unknown): string[] {
|
|
20
|
+
if (!Array.isArray(value)) return [];
|
|
21
|
+
return value
|
|
22
|
+
.filter((entry): entry is string => typeof entry === "string")
|
|
23
|
+
.map((entry) => entry.trim())
|
|
24
|
+
.filter((entry) => entry.length > 0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function expandPath(value: string, directory: string, home = homedir()): string {
|
|
28
|
+
let output = value;
|
|
29
|
+
if (output === "~") output = home;
|
|
30
|
+
else if (output.startsWith("~/")) output = resolve(home, output.slice(2));
|
|
31
|
+
return isAbsolute(output) ? resolve(output) : resolve(directory, output);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function parseOptions(raw: unknown, directory: string, home = homedir()): Options {
|
|
35
|
+
const input = record(raw);
|
|
36
|
+
const include = stringArray(input.include).filter((entry): entry is AssetKind =>
|
|
37
|
+
["plugins", "mcp", "skills"].includes(entry)
|
|
38
|
+
);
|
|
39
|
+
const lockfile =
|
|
40
|
+
typeof input.lockfile === "string" && input.lockfile.trim().length > 0
|
|
41
|
+
? expandPath(input.lockfile, directory, home)
|
|
42
|
+
: resolve(directory, DEFAULTS.lockfile);
|
|
43
|
+
const trace =
|
|
44
|
+
typeof input.trace === "string" && input.trace.trim().length > 0
|
|
45
|
+
? expandPath(input.trace, directory, home)
|
|
46
|
+
: undefined;
|
|
47
|
+
return {
|
|
48
|
+
lockfile,
|
|
49
|
+
mode: input.mode === "enforce" ? "enforce" : "warn",
|
|
50
|
+
include: include.length > 0 ? include : DEFAULTS.include,
|
|
51
|
+
ignore: stringArray(input.ignore),
|
|
52
|
+
includeGlobal: input.includeGlobal === true,
|
|
53
|
+
trace,
|
|
54
|
+
};
|
|
55
|
+
}
|