@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/v2.ts
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { mcpConfigAsset, normalizeSkillPath } from "./assets.ts";
|
|
3
|
+
import { describeAudit } from "./audit.ts";
|
|
4
|
+
import { readLockfile } from "./lockfile.ts";
|
|
5
|
+
import { parseOptions } from "./options.ts";
|
|
6
|
+
import {
|
|
7
|
+
changedMCPServers,
|
|
8
|
+
changedSkills,
|
|
9
|
+
inspectRuntime,
|
|
10
|
+
lockRuntime,
|
|
11
|
+
type RuntimeAudit,
|
|
12
|
+
type RuntimeContext,
|
|
13
|
+
skillDirectoryHashes,
|
|
14
|
+
} from "./runtime.ts";
|
|
15
|
+
import {
|
|
16
|
+
INTEGRITY_COMMAND,
|
|
17
|
+
LOCK_TOOL_DESCRIPTION,
|
|
18
|
+
LOCK_TOOL_INPUT,
|
|
19
|
+
LOCK_TOOL_NAME,
|
|
20
|
+
STATUS_TOOL_DESCRIPTION,
|
|
21
|
+
STATUS_TOOL_INPUT,
|
|
22
|
+
STATUS_TOOL_NAME,
|
|
23
|
+
statusTool,
|
|
24
|
+
} from "./tool.ts";
|
|
25
|
+
import { hashText } from "./util.ts";
|
|
26
|
+
import { VERSION } from "./version.ts";
|
|
27
|
+
|
|
28
|
+
type Json = Record<string, unknown>;
|
|
29
|
+
interface Disposable {
|
|
30
|
+
dispose(): Promise<void> | void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface V2Context extends RuntimeContext {
|
|
34
|
+
readonly location?: { readonly directory?: string };
|
|
35
|
+
readonly options?: unknown;
|
|
36
|
+
readonly app?: { readonly version?: string };
|
|
37
|
+
readonly permission?: {
|
|
38
|
+
assert(input: Json): Promise<void>;
|
|
39
|
+
hook?(name: "evaluate", callback: (event: Json) => Promise<void> | void): Promise<Disposable>;
|
|
40
|
+
};
|
|
41
|
+
readonly mcp?: {
|
|
42
|
+
list(): Promise<unknown>;
|
|
43
|
+
transform(
|
|
44
|
+
callback: (editor: {
|
|
45
|
+
list(): readonly [string, Json][];
|
|
46
|
+
update(name: string, update: (value: Json) => void): void;
|
|
47
|
+
}) => void
|
|
48
|
+
): Promise<Disposable>;
|
|
49
|
+
};
|
|
50
|
+
readonly skill?: {
|
|
51
|
+
list(): Promise<unknown>;
|
|
52
|
+
transform(
|
|
53
|
+
callback: (editor: { list(): readonly Json[]; remove(id: string): void }) => void
|
|
54
|
+
): Promise<Disposable>;
|
|
55
|
+
};
|
|
56
|
+
readonly command?: {
|
|
57
|
+
transform(callback: (editor: { add(command: Json): void }) => void): Promise<Disposable>;
|
|
58
|
+
};
|
|
59
|
+
readonly tool?: {
|
|
60
|
+
list(): Promise<readonly unknown[]>;
|
|
61
|
+
transform(callback: (editor: { add(tool: Json): void }) => void): Promise<Disposable>;
|
|
62
|
+
hook?(name: string, callback: (event: Json) => Promise<void> | void): Promise<Disposable>;
|
|
63
|
+
};
|
|
64
|
+
readonly session: {
|
|
65
|
+
hook(name: string, callback: (event: Json) => Promise<void> | void): Promise<Disposable>;
|
|
66
|
+
synthetic(input: { sessionID: string; text: string }): Promise<unknown>;
|
|
67
|
+
interrupt(input: { sessionID: string; resume?: boolean }): Promise<unknown>;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function object(value: unknown): Json {
|
|
72
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
73
|
+
? (value as Json)
|
|
74
|
+
: {};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function debug(...values: unknown[]): void {
|
|
78
|
+
if (process.env.OPENCODE_INTEGRITY_DEBUG === "1") {
|
|
79
|
+
console.error("[opencode-integrity:debug]", ...values);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function toolError(message: string): Promise<Error> {
|
|
84
|
+
try {
|
|
85
|
+
const specifier = "@opencode/plugin";
|
|
86
|
+
const module = (await import(specifier)) as {
|
|
87
|
+
Tool?: { Error?: new (input: { message: string }) => Error };
|
|
88
|
+
};
|
|
89
|
+
if (module.Tool?.Error !== undefined) return new module.Tool.Error({ message });
|
|
90
|
+
} catch {
|
|
91
|
+
// V1 or authoring package unavailable.
|
|
92
|
+
}
|
|
93
|
+
const error = new Error(message);
|
|
94
|
+
error.name = "Tool.Error";
|
|
95
|
+
return error;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function serverForTool(tool: string, servers: readonly string[]): string | undefined {
|
|
99
|
+
return [...servers]
|
|
100
|
+
.sort((a, b) => b.length - a.length)
|
|
101
|
+
.find((server) => {
|
|
102
|
+
const normalized = server.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
103
|
+
return tool.startsWith(`${normalized}_`) || tool.startsWith(`mcp__${normalized}__`);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function toolNameFromInput(input: unknown): string | undefined {
|
|
108
|
+
const value = object(input);
|
|
109
|
+
return typeof value.name === "string"
|
|
110
|
+
? value.name
|
|
111
|
+
: typeof value.id === "string"
|
|
112
|
+
? value.id
|
|
113
|
+
: undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function setup(
|
|
117
|
+
input: unknown,
|
|
118
|
+
environment: { home?: string } = {}
|
|
119
|
+
): Promise<() => Promise<void>> {
|
|
120
|
+
const ctx = input as V2Context;
|
|
121
|
+
const directory = ctx.location?.directory ?? process.cwd();
|
|
122
|
+
const home = environment.home ?? homedir();
|
|
123
|
+
const options = parseOptions(ctx.options, directory, home);
|
|
124
|
+
const disposables: Disposable[] = [];
|
|
125
|
+
const canForceAsk = ctx.permission?.hook !== undefined;
|
|
126
|
+
if (ctx.permission?.hook !== undefined) {
|
|
127
|
+
disposables.push(
|
|
128
|
+
await ctx.permission.hook("evaluate", (event) => {
|
|
129
|
+
if (event.action === "integrity_lock") event.effect = "ask";
|
|
130
|
+
})
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
let cached: RuntimeAudit | undefined;
|
|
134
|
+
let enforcement: RuntimeAudit | undefined;
|
|
135
|
+
let warnedSession: string | undefined;
|
|
136
|
+
|
|
137
|
+
const refresh = async (): Promise<RuntimeAudit> => {
|
|
138
|
+
cached = await inspectRuntime(ctx, options, directory, home);
|
|
139
|
+
return cached;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Enforce MCP configuration drift before a server is used.
|
|
143
|
+
if (
|
|
144
|
+
options.mode === "enforce" &&
|
|
145
|
+
options.include.includes("mcp") &&
|
|
146
|
+
ctx.mcp?.transform !== undefined
|
|
147
|
+
) {
|
|
148
|
+
const lock = await readLockfile(options.lockfile);
|
|
149
|
+
if (lock !== undefined) {
|
|
150
|
+
disposables.push(
|
|
151
|
+
await ctx.mcp.transform((editor) => {
|
|
152
|
+
for (const [name, config] of editor.list()) {
|
|
153
|
+
const current = mcpConfigAsset(name, config);
|
|
154
|
+
const previous = lock.assets.mcp[name];
|
|
155
|
+
if (current === undefined) continue;
|
|
156
|
+
if (previous === undefined || current.configHash !== previous.configHash) {
|
|
157
|
+
editor.update(name, (value) => {
|
|
158
|
+
value.disabled = true;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Enforce changed skills by removing them from the advertised set.
|
|
168
|
+
if (
|
|
169
|
+
options.mode === "enforce" &&
|
|
170
|
+
options.include.includes("skills") &&
|
|
171
|
+
ctx.skill?.transform !== undefined
|
|
172
|
+
) {
|
|
173
|
+
const lock = await readLockfile(options.lockfile);
|
|
174
|
+
const skillHashes = await skillDirectoryHashes(ctx, directory);
|
|
175
|
+
if (lock !== undefined) {
|
|
176
|
+
disposables.push(
|
|
177
|
+
await ctx.skill.transform((editor) => {
|
|
178
|
+
for (const skill of editor.list()) {
|
|
179
|
+
const id = typeof skill.id === "string" ? skill.id : undefined;
|
|
180
|
+
if (id === undefined) continue;
|
|
181
|
+
const absolutePath = typeof skill.path === "string" ? skill.path : "";
|
|
182
|
+
if (absolutePath.startsWith("skill://")) continue;
|
|
183
|
+
const path = normalizeSkillPath(directory, absolutePath);
|
|
184
|
+
const previous = lock.assets.skills[id];
|
|
185
|
+
const content = typeof skill.content === "string" ? skill.content : "";
|
|
186
|
+
const directoryHash = skillHashes.get(id);
|
|
187
|
+
if (
|
|
188
|
+
previous === undefined ||
|
|
189
|
+
previous.contentHash !== hashText(content) ||
|
|
190
|
+
previous.path !== path ||
|
|
191
|
+
directoryHash?.truncated === true ||
|
|
192
|
+
(directoryHash !== undefined && previous.directoryHash !== directoryHash.hash)
|
|
193
|
+
) {
|
|
194
|
+
editor.remove(id);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// First context: compute drift and inject a report once per session.
|
|
203
|
+
disposables.push(
|
|
204
|
+
await ctx.session.hook("context", async (event) => {
|
|
205
|
+
const sessionID = typeof event.sessionID === "string" ? event.sessionID : "";
|
|
206
|
+
if (sessionID.length === 0) return;
|
|
207
|
+
if (cached === undefined) await refresh();
|
|
208
|
+
if (cached === undefined || warnedSession === sessionID) return;
|
|
209
|
+
warnedSession = sessionID;
|
|
210
|
+
if (!cached.locked) {
|
|
211
|
+
const text = `opencode-integrity: no lockfile at ${options.lockfile}. Run /integrity lock to pin plugins, MCP tools and skills.`;
|
|
212
|
+
if (Array.isArray(event.system)) event.system.push({ type: "text", text });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (cached.drift.length === 0) return;
|
|
216
|
+
const text = `opencode-integrity: drift detected (${options.mode}).\n${describeAudit(cached, false)}`;
|
|
217
|
+
if (Array.isArray(event.system)) event.system.push({ type: "text", text });
|
|
218
|
+
})
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
// Deny changed MCP tools / skills in enforce mode.
|
|
222
|
+
if (ctx.tool?.hook !== undefined) {
|
|
223
|
+
disposables.push(
|
|
224
|
+
await ctx.tool.hook("execute.before", async (event) => {
|
|
225
|
+
if (options.mode !== "enforce") return;
|
|
226
|
+
// Refresh the security-relevant surface before every call. MCP tool
|
|
227
|
+
// descriptors and skill files can change after the first context.
|
|
228
|
+
const enforcementOptions = {
|
|
229
|
+
...options,
|
|
230
|
+
include: options.include.filter((kind) => kind !== "plugins"),
|
|
231
|
+
};
|
|
232
|
+
enforcement = await inspectRuntime(ctx, enforcementOptions, directory, home);
|
|
233
|
+
if (!enforcement.locked) return;
|
|
234
|
+
const tool = typeof event.tool === "string" ? event.tool : "";
|
|
235
|
+
if (tool === "execute") {
|
|
236
|
+
// Code Mode can reach any MCP tool or skill through property access
|
|
237
|
+
// or aliases, so a string search is not a security boundary. Fail
|
|
238
|
+
// closed for the whole catalog while either asset class drifts.
|
|
239
|
+
if (
|
|
240
|
+
(options.include.includes("mcp") && changedMCPServers(enforcement).size > 0) ||
|
|
241
|
+
(options.include.includes("skills") && changedSkills(enforcement).size > 0)
|
|
242
|
+
) {
|
|
243
|
+
throw await toolError(
|
|
244
|
+
"MCP tools or skills changed since opencode.lock.json; review and run /integrity lock"
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (tool === "skill") {
|
|
250
|
+
const name = toolNameFromInput(event.input);
|
|
251
|
+
if (name !== undefined && changedSkills(enforcement).has(name)) {
|
|
252
|
+
throw await toolError(
|
|
253
|
+
`skill ${name} changed since opencode.lock.json; review and run /integrity lock`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const changedServers = changedMCPServers(enforcement);
|
|
259
|
+
const server = serverForTool(tool, Object.keys(enforcement.assets.mcp));
|
|
260
|
+
if (server !== undefined && changedServers.has(server)) {
|
|
261
|
+
throw await toolError(
|
|
262
|
+
`MCP server ${server} changed since opencode.lock.json; review and run /integrity lock`
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
const changedTool = enforcement.drift.find(
|
|
266
|
+
(entry) => entry.asset === "mcp-tool" && entry.id === tool
|
|
267
|
+
);
|
|
268
|
+
if (changedTool !== undefined) {
|
|
269
|
+
throw await toolError(
|
|
270
|
+
`${tool} changed since opencode.lock.json; review and run /integrity lock`
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
})
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (ctx.command?.transform !== undefined) {
|
|
278
|
+
disposables.push(
|
|
279
|
+
await ctx.command.transform((editor) => {
|
|
280
|
+
editor.add({
|
|
281
|
+
name: INTEGRITY_COMMAND.name,
|
|
282
|
+
description: INTEGRITY_COMMAND.description,
|
|
283
|
+
execute: async (commandInput: Json) => {
|
|
284
|
+
const sessionID =
|
|
285
|
+
typeof commandInput.sessionID === "string" ? commandInput.sessionID : "";
|
|
286
|
+
if (sessionID.length === 0) return;
|
|
287
|
+
const prompt = object(commandInput.prompt);
|
|
288
|
+
const text =
|
|
289
|
+
typeof prompt.text === "string"
|
|
290
|
+
? prompt.text
|
|
291
|
+
: typeof commandInput.text === "string"
|
|
292
|
+
? commandInput.text
|
|
293
|
+
: "";
|
|
294
|
+
const action = text.trim().split(/\s+/)[0] ?? "audit";
|
|
295
|
+
try {
|
|
296
|
+
if (action === "lock") {
|
|
297
|
+
if (sessionID.length > 0 && ctx.permission?.assert !== undefined) {
|
|
298
|
+
await ctx.permission.assert({
|
|
299
|
+
sessionID,
|
|
300
|
+
action: "integrity_lock",
|
|
301
|
+
resources: [options.lockfile],
|
|
302
|
+
save: [],
|
|
303
|
+
metadata: { source: "opencode-integrity" },
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
const result = await lockRuntime(ctx, options, directory, home);
|
|
307
|
+
cached = undefined;
|
|
308
|
+
enforcement = undefined;
|
|
309
|
+
await ctx.session.synthetic({
|
|
310
|
+
sessionID,
|
|
311
|
+
text: `opencode-integrity: locked ${result.path} (${Object.keys(result.lockfile.assets.plugins).length} plugins, ${Object.keys(result.lockfile.assets.mcp).length} MCP servers, ${Object.keys(result.lockfile.assets.skills).length} skills).`,
|
|
312
|
+
});
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const result = await refresh();
|
|
316
|
+
await ctx.session.synthetic({
|
|
317
|
+
sessionID,
|
|
318
|
+
text: `opencode-integrity ${VERSION} (${action}):\n${describeAudit(result, true)}`,
|
|
319
|
+
});
|
|
320
|
+
} catch (error) {
|
|
321
|
+
await ctx.session.synthetic({
|
|
322
|
+
sessionID,
|
|
323
|
+
text: `opencode-integrity: ${error instanceof Error ? error.message : String(error)}`,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
})
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (ctx.tool?.transform !== undefined) {
|
|
333
|
+
disposables.push(
|
|
334
|
+
await ctx.tool.transform((editor) => {
|
|
335
|
+
editor.add({
|
|
336
|
+
name: STATUS_TOOL_NAME,
|
|
337
|
+
description: STATUS_TOOL_DESCRIPTION,
|
|
338
|
+
input: STATUS_TOOL_INPUT,
|
|
339
|
+
execute: async (toolInput: unknown) => ({
|
|
340
|
+
content: (await statusTool(refresh, toolInput)).content,
|
|
341
|
+
}),
|
|
342
|
+
});
|
|
343
|
+
if (canForceAsk)
|
|
344
|
+
editor.add({
|
|
345
|
+
name: LOCK_TOOL_NAME,
|
|
346
|
+
description: LOCK_TOOL_DESCRIPTION,
|
|
347
|
+
input: LOCK_TOOL_INPUT,
|
|
348
|
+
execute: async (_toolInput: unknown, context: Json) => {
|
|
349
|
+
const sessionID =
|
|
350
|
+
typeof context.sessionID === "string" ? context.sessionID : undefined;
|
|
351
|
+
if (sessionID === undefined || ctx.permission?.assert === undefined) {
|
|
352
|
+
throw await toolError("integrity_lock requires an interactive session approval");
|
|
353
|
+
}
|
|
354
|
+
await ctx.permission.assert({
|
|
355
|
+
sessionID,
|
|
356
|
+
action: "integrity_lock",
|
|
357
|
+
resources: [options.lockfile],
|
|
358
|
+
save: [],
|
|
359
|
+
metadata: { source: "opencode-integrity" },
|
|
360
|
+
});
|
|
361
|
+
const result = await lockRuntime(ctx, options, directory, home);
|
|
362
|
+
cached = undefined;
|
|
363
|
+
enforcement = undefined;
|
|
364
|
+
return {
|
|
365
|
+
content: `locked ${result.path} (${Object.keys(result.lockfile.assets.plugins).length} plugins, ${Object.keys(result.lockfile.assets.mcp).length} MCP servers, ${Object.keys(result.lockfile.assets.skills).length} skills).`,
|
|
366
|
+
};
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
})
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
debug("ready", directory, options.mode);
|
|
374
|
+
console.error(`[opencode-integrity] ready (mode=${options.mode}, lock=${options.lockfile})`);
|
|
375
|
+
|
|
376
|
+
return async () => {
|
|
377
|
+
for (const disposable of disposables.reverse()) {
|
|
378
|
+
try {
|
|
379
|
+
await disposable.dispose();
|
|
380
|
+
} catch {
|
|
381
|
+
// Unload must not throw.
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const VERSION = "0.1.0";
|