@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/src/policy.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { Assets } from "./types.ts";
2
+
3
+ export interface PolicyStatement {
4
+ readonly action: "mcp.use" | "skill.use" | "plugin.use";
5
+ readonly resource: string;
6
+ readonly effect: "allow";
7
+ }
8
+
9
+ /** Generate a native OpenCode allowlist from the locked asset names. */
10
+ export function generatePolicy(assets: Assets): { policies: PolicyStatement[] } {
11
+ const policies: PolicyStatement[] = [];
12
+ for (const id of Object.keys(assets.mcp).sort()) {
13
+ policies.push({ action: "mcp.use", resource: id, effect: "allow" });
14
+ }
15
+ for (const id of Object.keys(assets.skills).sort()) {
16
+ policies.push({ action: "skill.use", resource: id, effect: "allow" });
17
+ }
18
+ for (const asset of Object.values(assets.plugins)) {
19
+ const resource = asset.source.startsWith(".")
20
+ ? asset.source
21
+ : asset.source.split("@").slice(0, 2).join("@");
22
+ policies.push({ action: "plugin.use", resource, effect: "allow" });
23
+ }
24
+ return { policies };
25
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,213 @@
1
+ import { dirname } from "node:path";
2
+ import {
3
+ mcpConfigAsset,
4
+ mcpToolsForServer,
5
+ pluginAsset,
6
+ pluginIDFromRuntime,
7
+ skillAssetFromInfo,
8
+ } from "./assets.ts";
9
+ import { filterAssets, filterIgnored } from "./audit.ts";
10
+ import { loadGlobalMcp, loadProjectConfig } from "./config.ts";
11
+ import { audit, createLockfile, readLockfile, writeLockfile } from "./lockfile.ts";
12
+ import type { Assets, AuditResult, Lockfile, Options } from "./types.ts";
13
+ import { hashDirectory, isInside } from "./util.ts";
14
+ import { VERSION as PLUGIN_VERSION } from "./version.ts";
15
+
16
+ export interface RuntimeContext {
17
+ readonly app?: { readonly version?: string };
18
+ readonly plugin?: { list(): Promise<unknown> };
19
+ readonly mcp?: { list(): Promise<unknown> };
20
+ readonly skill?: { list(): Promise<unknown> };
21
+ readonly tool?: { list(): Promise<readonly unknown[]> };
22
+ }
23
+
24
+ interface RuntimeData {
25
+ readonly plugins: readonly unknown[];
26
+ readonly mcpNames: readonly string[];
27
+ readonly skills: readonly unknown[];
28
+ readonly tools: readonly unknown[];
29
+ readonly version?: string;
30
+ }
31
+
32
+ function dataArray(value: unknown): readonly unknown[] {
33
+ if (Array.isArray(value)) return value;
34
+ if (typeof value === "object" && value !== null) {
35
+ const data = (value as { data?: unknown }).data;
36
+ if (Array.isArray(data)) return data;
37
+ }
38
+ return [];
39
+ }
40
+
41
+ function object(value: unknown): Record<string, unknown> {
42
+ return typeof value === "object" && value !== null && !Array.isArray(value)
43
+ ? (value as Record<string, unknown>)
44
+ : {};
45
+ }
46
+
47
+ async function readRuntime(ctx: RuntimeContext, include: readonly string[]): Promise<RuntimeData> {
48
+ const plugins =
49
+ include.includes("plugins") && ctx.plugin !== undefined
50
+ ? dataArray(await ctx.plugin.list())
51
+ : [];
52
+ const mcp =
53
+ include.includes("mcp") && ctx.mcp !== undefined ? dataArray(await ctx.mcp.list()) : [];
54
+ const skills =
55
+ include.includes("skills") && ctx.skill !== undefined ? dataArray(await ctx.skill.list()) : [];
56
+ const tools =
57
+ include.includes("mcp") && ctx.tool !== undefined ? ((await ctx.tool.list()) ?? []) : [];
58
+ const mcpNames = mcp
59
+ .map((entry) =>
60
+ typeof object(entry).name === "string" ? (object(entry).name as string) : undefined
61
+ )
62
+ .filter((entry): entry is string => entry !== undefined);
63
+ return { plugins, mcpNames, skills, tools, version: ctx.app?.version };
64
+ }
65
+
66
+ export interface RuntimeSnapshot {
67
+ readonly assets: Assets;
68
+ readonly warnings: readonly string[];
69
+ readonly version?: string;
70
+ }
71
+
72
+ /** Collect the assets OpenCode actually loaded, including live MCP tool descriptors. */
73
+ export async function collectRuntime(
74
+ ctx: RuntimeContext,
75
+ directory: string,
76
+ home: string,
77
+ options?: Pick<Options, "include" | "includeGlobal">
78
+ ): Promise<RuntimeSnapshot> {
79
+ const include = options?.include ?? ["plugins", "mcp", "skills"];
80
+ const runtime = await readRuntime(ctx, include);
81
+ const config = await loadProjectConfig(directory);
82
+ const includeGlobal = options?.includeGlobal === true;
83
+ const projectTargets = new Set(config.plugins.map((ref) => ref.target));
84
+ const warnings = [...config.warnings];
85
+ const plugins: Record<string, Assets["plugins"][string]> = {};
86
+
87
+ for (const entry of runtime.plugins) {
88
+ const id = pluginIDFromRuntime(entry);
89
+ if (id === undefined) continue;
90
+ const source = object(object(entry).source);
91
+ const ref =
92
+ source.type === "local" && typeof source.path === "string"
93
+ ? { target: source.path, localPath: source.path }
94
+ : source.type === "package" && typeof source.target === "string"
95
+ ? {
96
+ target: source.target,
97
+ version: typeof source.version === "string" ? source.version : undefined,
98
+ }
99
+ : undefined;
100
+ if (ref === undefined) continue;
101
+ const included =
102
+ includeGlobal ||
103
+ projectTargets.has(ref.target) ||
104
+ (source.type === "local" &&
105
+ typeof source.path === "string" &&
106
+ isInside(directory, source.path));
107
+ if (!included) continue;
108
+ const result = await pluginAsset(id, ref, directory, home, includeGlobal);
109
+ if (result.asset !== undefined) plugins[id] = result.asset;
110
+ if (result.warning !== undefined) warnings.push(result.warning);
111
+ }
112
+
113
+ const mcp: Record<string, Assets["mcp"][string]> = {};
114
+ const mcpConfig = includeGlobal ? { ...(await loadGlobalMcp(home)), ...config.mcp } : config.mcp;
115
+ for (const [name, value] of Object.entries(mcpConfig)) {
116
+ const asset = mcpConfigAsset(name, value);
117
+ if (asset === undefined) continue;
118
+ if (runtime.mcpNames.length > 0 && !runtime.mcpNames.includes(name)) continue;
119
+ const tools = mcpToolsForServer(runtime.tools, name);
120
+ mcp[name] = {
121
+ ...asset,
122
+ tools,
123
+ toolsSource: Object.keys(tools).length > 0 ? "runtime" : "none",
124
+ };
125
+ }
126
+
127
+ const skills: Record<string, Assets["skills"][string]> = {};
128
+ for (const entry of runtime.skills) {
129
+ const entryPath = typeof object(entry).path === "string" ? (object(entry).path as string) : "";
130
+ if (entryPath.startsWith("skill://")) continue;
131
+ if (!includeGlobal && !isInside(directory, entryPath)) continue;
132
+ const pair = await skillAssetFromInfo(entry, directory);
133
+ if (pair !== undefined) skills[pair[0]] = pair[1];
134
+ }
135
+
136
+ return {
137
+ assets: filterAssets({ plugins, mcp, skills }, include),
138
+ warnings,
139
+ version: runtime.version,
140
+ };
141
+ }
142
+
143
+ export interface RuntimeAudit extends AuditResult {
144
+ readonly snapshot: RuntimeSnapshot;
145
+ readonly lockfile?: Lockfile;
146
+ }
147
+
148
+ export async function inspectRuntime(
149
+ ctx: RuntimeContext,
150
+ options: Options,
151
+ directory: string,
152
+ home: string
153
+ ): Promise<RuntimeAudit> {
154
+ const snapshot = await collectRuntime(ctx, directory, home, options);
155
+ const lockfile = await readLockfile(options.lockfile);
156
+ const filteredLock =
157
+ lockfile === undefined ? undefined : filterAssets(lockfile.assets, options.include);
158
+ const result = audit(filteredLock, snapshot.assets, snapshot.warnings);
159
+ const drift = filterIgnored(result.drift, options.ignore);
160
+ return {
161
+ ...result,
162
+ drift,
163
+ ok: drift.length === 0,
164
+ snapshot,
165
+ lockfile,
166
+ };
167
+ }
168
+
169
+ export async function lockRuntime(
170
+ ctx: RuntimeContext,
171
+ options: Options,
172
+ directory: string,
173
+ home: string
174
+ ): Promise<{ lockfile: Lockfile; path: string; snapshot: RuntimeSnapshot }> {
175
+ const snapshot = await collectRuntime(ctx, directory, home, options);
176
+ const lockfile = createLockfile(snapshot.assets, snapshot.version, PLUGIN_VERSION);
177
+ await writeLockfile(options.lockfile, lockfile);
178
+ return { lockfile, path: options.lockfile, snapshot };
179
+ }
180
+
181
+ export function changedMCPServers(result: AuditResult): Set<string> {
182
+ return new Set(
183
+ result.drift
184
+ .filter((entry) => entry.asset === "mcp" || entry.asset === "mcp-tool")
185
+ .map((entry) => (entry.asset === "mcp-tool" ? entry.parent : entry.id))
186
+ .filter((entry): entry is string => entry !== undefined)
187
+ );
188
+ }
189
+
190
+ export async function skillDirectoryHashes(
191
+ ctx: RuntimeContext,
192
+ _directory: string
193
+ ): Promise<Map<string, { hash: string; truncated: boolean }>> {
194
+ const output = new Map<string, { hash: string; truncated: boolean }>();
195
+ if (ctx.skill === undefined) return output;
196
+ for (const entry of dataArray(await ctx.skill.list())) {
197
+ const value = object(entry);
198
+ const id = typeof value.id === "string" ? value.id : undefined;
199
+ const path = typeof value.path === "string" ? value.path : undefined;
200
+ if (id === undefined || path === undefined || path.startsWith("skill://")) continue;
201
+ const hash = await hashDirectory(dirname(path));
202
+ output.set(id, { hash: hash.hash, truncated: hash.truncated });
203
+ }
204
+ return output;
205
+ }
206
+
207
+ export function changedSkills(result: AuditResult): Set<string> {
208
+ return new Set(result.drift.filter((entry) => entry.asset === "skill").map((entry) => entry.id));
209
+ }
210
+
211
+ export function changedPlugins(result: AuditResult): Set<string> {
212
+ return new Set(result.drift.filter((entry) => entry.asset === "plugin").map((entry) => entry.id));
213
+ }
package/src/sarif.ts ADDED
@@ -0,0 +1,71 @@
1
+ import type { DriftEntry } from "./types.ts";
2
+
3
+ const RULES = [
4
+ "plugin-changed",
5
+ "plugin-added",
6
+ "plugin-removed",
7
+ "mcp-changed",
8
+ "mcp-added",
9
+ "mcp-removed",
10
+ "mcp-tool-changed",
11
+ "mcp-tool-added",
12
+ "mcp-tool-removed",
13
+ "skill-changed",
14
+ "skill-added",
15
+ "skill-removed",
16
+ "lockfile-missing",
17
+ ] as const;
18
+
19
+ function ruleID(entry: DriftEntry): string {
20
+ const suffix =
21
+ entry.asset === "mcp-tool" ? `mcp-tool-${entry.change}` : `${entry.asset}-${entry.change}`;
22
+ return `integrity/${RULES.includes(suffix as (typeof RULES)[number]) ? suffix : "plugin-changed"}`;
23
+ }
24
+
25
+ /** SARIF 2.1.0 for GitHub code scanning and other CI dashboards. */
26
+ export function toSarif(
27
+ drift: readonly DriftEntry[],
28
+ version: string,
29
+ lockfile: string,
30
+ missingLockfile = false
31
+ ): unknown {
32
+ return {
33
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
34
+ version: "2.1.0",
35
+ runs: [
36
+ {
37
+ tool: {
38
+ driver: {
39
+ name: "opencode-integrity",
40
+ informationUri: "https://github.com/Hamza-Abili/opencode-integrity",
41
+ version,
42
+ rules: RULES.map((id) => ({ id: `integrity/${id}` })),
43
+ },
44
+ },
45
+ results: [
46
+ ...(missingLockfile
47
+ ? [
48
+ {
49
+ ruleId: "integrity/lockfile-missing",
50
+ level: "error",
51
+ message: { text: `no lockfile at ${lockfile}; run integrity lock` },
52
+ locations: [{ physicalLocation: { artifactLocation: { uri: lockfile } } }],
53
+ },
54
+ ]
55
+ : []),
56
+ ...drift.map((entry) => ({
57
+ ruleId: ruleID(entry),
58
+ level:
59
+ entry.severity === "high"
60
+ ? "error"
61
+ : entry.severity === "medium"
62
+ ? "warning"
63
+ : "note",
64
+ message: { text: entry.message },
65
+ locations: [{ physicalLocation: { artifactLocation: { uri: lockfile } } }],
66
+ })),
67
+ ],
68
+ },
69
+ ],
70
+ };
71
+ }
package/src/tool.ts ADDED
@@ -0,0 +1,53 @@
1
+ import type { RuntimeAudit } from "./runtime.ts";
2
+
3
+ export const STATUS_TOOL_NAME = "integrity_status";
4
+
5
+ export const STATUS_TOOL_DESCRIPTION =
6
+ "Report OpenCode plugin, MCP tool and skill integrity against opencode.lock.json, including drift and detected capabilities.";
7
+
8
+ export const STATUS_TOOL_INPUT = {
9
+ type: "object",
10
+ properties: {
11
+ format: {
12
+ type: "string",
13
+ enum: ["text", "json"],
14
+ description: "text for a readable report, json for machine-readable output",
15
+ },
16
+ },
17
+ additionalProperties: false,
18
+ } as const;
19
+
20
+ export const LOCK_TOOL_NAME = "integrity_lock";
21
+
22
+ export const LOCK_TOOL_DESCRIPTION =
23
+ "Write or refresh opencode.lock.json from the currently loaded plugins, MCP tool descriptors and skills. Ask the user before calling this.";
24
+
25
+ export const LOCK_TOOL_INPUT = {
26
+ type: "object",
27
+ properties: {},
28
+ additionalProperties: false,
29
+ } as const;
30
+
31
+ export const INTEGRITY_COMMAND = {
32
+ name: "integrity",
33
+ description: "Audit or lock plugins, MCP tools and skills (audit | lock | check)",
34
+ } as const;
35
+
36
+ export async function statusTool(
37
+ refresh: () => Promise<RuntimeAudit>,
38
+ input: unknown
39
+ ): Promise<{ content: string }> {
40
+ const format =
41
+ typeof input === "object" && input !== null && (input as { format?: unknown }).format === "json"
42
+ ? "json"
43
+ : "text";
44
+ const result = await refresh();
45
+ if (format === "json") return { content: JSON.stringify(result, null, 2) };
46
+ const lines = [
47
+ `opencode-integrity ${result.locked ? "locked" : "unlocked"} — ${result.ok ? "OK" : "DRIFT"}`,
48
+ `assets: ${Object.keys(result.assets.plugins).length} plugin(s), ${Object.keys(result.assets.mcp).length} MCP server(s), ${Object.keys(result.assets.skills).length} skill(s)`,
49
+ ];
50
+ for (const entry of result.drift) lines.push(`${entry.severity}: ${entry.message}`);
51
+ for (const warning of result.warnings) lines.push(`warning: ${warning}`);
52
+ return { content: lines.join("\n") };
53
+ }
package/src/types.ts ADDED
@@ -0,0 +1,102 @@
1
+ export const SCHEMA_VERSION = 1 as const;
2
+
3
+ export type AssetKind = "plugins" | "mcp" | "skills";
4
+
5
+ export interface PluginAsset {
6
+ readonly source: string;
7
+ readonly version?: string;
8
+ readonly contentHash?: string;
9
+ readonly packageHash?: string;
10
+ readonly files?: number;
11
+ readonly bytes?: number;
12
+ readonly capabilities?: readonly string[];
13
+ readonly truncated?: boolean;
14
+ }
15
+
16
+ export interface McpToolAsset {
17
+ readonly descriptorHash: string;
18
+ readonly descriptionHash: string;
19
+ readonly inputHash: string;
20
+ readonly suspicious?: readonly string[];
21
+ }
22
+
23
+ export interface McpAsset {
24
+ readonly transport: "local" | "remote";
25
+ readonly configHash: string;
26
+ readonly command?: string;
27
+ readonly url?: string;
28
+ readonly envKeys?: readonly string[];
29
+ readonly tools?: Readonly<Record<string, McpToolAsset>>;
30
+ readonly toolsSource?: "runtime" | "none";
31
+ }
32
+
33
+ export interface SkillAsset {
34
+ readonly path: string;
35
+ readonly contentHash: string;
36
+ readonly directoryHash?: string;
37
+ readonly files?: number;
38
+ readonly bytes: number;
39
+ readonly truncated?: boolean;
40
+ readonly suspicious?: readonly string[];
41
+ }
42
+
43
+ export interface Assets {
44
+ readonly plugins: Readonly<Record<string, PluginAsset>>;
45
+ readonly mcp: Readonly<Record<string, McpAsset>>;
46
+ readonly skills: Readonly<Record<string, SkillAsset>>;
47
+ }
48
+
49
+ export interface Lockfile {
50
+ readonly schemaVersion: typeof SCHEMA_VERSION;
51
+ readonly generatedBy: string;
52
+ readonly opencode?: string;
53
+ readonly assets: Assets;
54
+ }
55
+
56
+ export type Change = "added" | "removed" | "changed";
57
+ export type Severity = "high" | "medium" | "low";
58
+
59
+ export interface DriftEntry {
60
+ readonly asset: "plugin" | "mcp" | "mcp-tool" | "skill";
61
+ readonly id: string;
62
+ readonly parent?: string;
63
+ readonly change: Change;
64
+ readonly field?: string;
65
+ readonly severity: Severity;
66
+ readonly message: string;
67
+ }
68
+
69
+ export interface AuditResult {
70
+ readonly locked: boolean;
71
+ readonly ok: boolean;
72
+ readonly drift: readonly DriftEntry[];
73
+ readonly warnings: readonly string[];
74
+ readonly assets: Assets;
75
+ readonly lockedAssets?: Assets;
76
+ }
77
+
78
+ export interface Options {
79
+ readonly lockfile: string;
80
+ readonly mode: "warn" | "enforce";
81
+ readonly include: readonly AssetKind[];
82
+ readonly ignore: readonly string[];
83
+ readonly includeGlobal: boolean;
84
+ readonly trace?: string;
85
+ }
86
+
87
+ export interface Host {
88
+ readonly directory: string;
89
+ readonly home: string;
90
+ readonly env: Readonly<Record<string, string | undefined>>;
91
+ readonly version?: string;
92
+ readonly status?: (message: string) => void;
93
+ }
94
+
95
+ /** Test seam: adapters fall back to the real home directory. */
96
+ export interface AdapterEnvironment {
97
+ readonly home?: string;
98
+ }
99
+
100
+ export function emptyAssets(): Assets {
101
+ return { plugins: {}, mcp: {}, skills: {} };
102
+ }