@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/util.ts ADDED
@@ -0,0 +1,349 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import type { Dirent } from "node:fs";
3
+ import {
4
+ mkdir,
5
+ readdir,
6
+ readFile,
7
+ readlink,
8
+ realpath,
9
+ rename,
10
+ stat,
11
+ writeFile,
12
+ } from "node:fs/promises";
13
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
14
+
15
+ export async function readTextFileCapped(
16
+ path: string,
17
+ maxBytes = 2 * 1024 * 1024
18
+ ): Promise<string | undefined> {
19
+ try {
20
+ const info = await stat(path);
21
+ if (info.size > maxBytes) return undefined;
22
+ } catch {
23
+ return undefined;
24
+ }
25
+ return await readTextFile(path);
26
+ }
27
+
28
+ export async function readTextFile(path: string): Promise<string | undefined> {
29
+ try {
30
+ return await readFile(path, "utf8");
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ }
35
+
36
+ export async function readJsonFileCapped<T>(
37
+ path: string,
38
+ maxBytes = 2 * 1024 * 1024
39
+ ): Promise<T | undefined> {
40
+ const text = await readTextFileCapped(path, maxBytes);
41
+ if (text === undefined) return undefined;
42
+ try {
43
+ return JSON.parse(text) as T;
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ }
48
+
49
+ export async function readJsonFile<T>(path: string): Promise<T | undefined> {
50
+ const text = await readTextFile(path);
51
+ if (text === undefined) return undefined;
52
+ try {
53
+ return JSON.parse(text) as T;
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ }
58
+
59
+ export async function pathExists(path: string): Promise<boolean> {
60
+ try {
61
+ await stat(path);
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ export async function isDirectory(path: string): Promise<boolean> {
69
+ try {
70
+ return (await stat(path)).isDirectory();
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ export async function writeFileAtomic(path: string, content: string, mode = 0o644): Promise<void> {
77
+ await mkdir(dirname(path), { recursive: true });
78
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
79
+ await writeFile(temporary, content, { mode });
80
+ await rename(temporary, path);
81
+ }
82
+
83
+ export function sha256(value: string): string {
84
+ return createHash("sha256").update(value).digest("hex");
85
+ }
86
+
87
+ export function hashText(value: string): string {
88
+ return `sha256:${sha256(value)}`;
89
+ }
90
+
91
+ export function shortHash(value: string): string {
92
+ return value.replace(/^sha256:/, "").slice(0, 12);
93
+ }
94
+
95
+ /** Deterministic JSON: object keys are sorted at every depth. */
96
+ export function stableStringify(value: unknown): string {
97
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
98
+ if (Array.isArray(value)) return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
99
+ const record = value as Record<string, unknown>;
100
+ const keys = Object.keys(record).sort();
101
+ return `{${keys
102
+ .filter((key) => record[key] !== undefined)
103
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
104
+ .join(",")}}`;
105
+ }
106
+
107
+ export function hashObject(value: unknown): string {
108
+ return hashText(stableStringify(value));
109
+ }
110
+
111
+ export function isInside(parent: string, child: string): boolean {
112
+ const rel = relative(resolve(parent), resolve(child));
113
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
114
+ }
115
+
116
+ export interface DirectoryHash {
117
+ readonly hash: string;
118
+ readonly files: number;
119
+ readonly bytes: number;
120
+ readonly truncated: boolean;
121
+ }
122
+
123
+ const IGNORED_DIRS = new Set([".git"]);
124
+
125
+ /**
126
+ * Hash a package/plugin directory. Build output (`dist`) and hidden files are
127
+ * included because they can be executed; symlinks are recorded by target.
128
+ */
129
+ export async function hashDirectory(
130
+ root: string,
131
+ maxFiles = 5000,
132
+ maxBytes = 50 * 1024 * 1024
133
+ ): Promise<DirectoryHash> {
134
+ const files: Array<{ path: string; content: Buffer }> = [];
135
+ const visited = new Set<string>();
136
+ let bytes = 0;
137
+ let truncated = false;
138
+
139
+ async function walk(directory: string, depth: number): Promise<void> {
140
+ if (truncated) return;
141
+ if (depth > 32) {
142
+ truncated = true;
143
+ return;
144
+ }
145
+ let real: string;
146
+ try {
147
+ real = await realpath(directory);
148
+ } catch {
149
+ return;
150
+ }
151
+ if (visited.has(real)) return;
152
+ visited.add(real);
153
+ let entries: Dirent[];
154
+ try {
155
+ entries = await readdir(directory, { withFileTypes: true });
156
+ } catch {
157
+ return;
158
+ }
159
+ entries.sort((a, b) => a.name.localeCompare(b.name));
160
+ for (const entry of entries) {
161
+ if (files.length >= maxFiles || bytes >= maxBytes) {
162
+ truncated = true;
163
+ return;
164
+ }
165
+ if (entry.isDirectory()) {
166
+ if (IGNORED_DIRS.has(entry.name)) continue;
167
+ await walk(join(directory, entry.name), depth + 1);
168
+ continue;
169
+ }
170
+ const path = join(directory, entry.name);
171
+ if (entry.isSymbolicLink()) {
172
+ let target = "";
173
+ try {
174
+ target = await readlink(path);
175
+ } catch {
176
+ continue;
177
+ }
178
+ let content = Buffer.from(`link:${target}`);
179
+ try {
180
+ const targetPath = resolve(dirname(path), target);
181
+ const info = await stat(targetPath);
182
+ if (info.isDirectory()) {
183
+ await walk(targetPath, depth + 1);
184
+ } else if (info.isFile()) {
185
+ if (info.size > maxBytes - bytes) {
186
+ truncated = true;
187
+ return;
188
+ }
189
+ const targetContent = await readFile(targetPath);
190
+ bytes += targetContent.byteLength;
191
+ content = Buffer.from(`link:${target}\0${sha256(targetContent.toString("base64"))}`);
192
+ }
193
+ } catch {
194
+ // Broken link: the target string is still hashed.
195
+ }
196
+ files.push({ path: relative(root, path), content });
197
+ bytes += content.byteLength;
198
+ continue;
199
+ }
200
+ if (!entry.isFile()) continue;
201
+ try {
202
+ const info = await stat(path);
203
+ if (info.size > maxBytes - bytes) {
204
+ truncated = true;
205
+ return;
206
+ }
207
+ const content = await readFile(path);
208
+ files.push({ path: relative(root, path), content });
209
+ bytes += content.byteLength;
210
+ } catch {
211
+ // Unreadable files are skipped, not fatal.
212
+ }
213
+ }
214
+ }
215
+
216
+ await walk(root, 0);
217
+ const lines = files
218
+ .sort((a, b) => a.path.localeCompare(b.path))
219
+ .map((file) => `${file.path}\0${sha256(file.content.toString("base64"))}`);
220
+ return {
221
+ hash: hashText(lines.join("\n")),
222
+ files: files.length,
223
+ bytes,
224
+ truncated,
225
+ };
226
+ }
227
+
228
+ /** Strip comments and trailing commas from JSONC, preserving string contents. */
229
+ export function parseJsonc(text: string): unknown {
230
+ let output = "";
231
+ let inString = false;
232
+ let escaped = false;
233
+ for (let index = 0; index < text.length; index += 1) {
234
+ const character = text[index] ?? "";
235
+ const next = text[index + 1] ?? "";
236
+ if (inString) {
237
+ output += character;
238
+ if (escaped) escaped = false;
239
+ else if (character === "\\") escaped = true;
240
+ else if (character === '"') inString = false;
241
+ continue;
242
+ }
243
+ if (character === '"') {
244
+ inString = true;
245
+ output += character;
246
+ continue;
247
+ }
248
+ if (character === "/" && next === "/") {
249
+ while (index < text.length && text[index] !== "\n") index += 1;
250
+ output += "\n";
251
+ continue;
252
+ }
253
+ if (character === "/" && next === "*") {
254
+ index += 2;
255
+ while (index < text.length && !(text[index] === "*" && text[index + 1] === "/")) index += 1;
256
+ index += 1;
257
+ output += " ";
258
+ continue;
259
+ }
260
+ output += character;
261
+ }
262
+ let cleaned = "";
263
+ let string = false;
264
+ let escapedInString = false;
265
+ for (let index = 0; index < output.length; index += 1) {
266
+ const character = output[index] ?? "";
267
+ if (string) {
268
+ cleaned += character;
269
+ if (escapedInString) escapedInString = false;
270
+ else if (character === "\\") escapedInString = true;
271
+ else if (character === '"') string = false;
272
+ continue;
273
+ }
274
+ if (character === '"') {
275
+ string = true;
276
+ cleaned += character;
277
+ continue;
278
+ }
279
+ if (character === ",") {
280
+ let next = index + 1;
281
+ while (next < output.length && /\s/.test(output[next] ?? "")) next += 1;
282
+ const following = output[next] ?? "";
283
+ if (following === "}" || following === "]") continue;
284
+ }
285
+ cleaned += character;
286
+ }
287
+ return JSON.parse(cleaned);
288
+ }
289
+
290
+ export async function readJsoncFile(path: string): Promise<unknown> {
291
+ const text = await readTextFile(path);
292
+ if (text === undefined) return undefined;
293
+ try {
294
+ return parseJsonc(text);
295
+ } catch {
296
+ return undefined;
297
+ }
298
+ }
299
+
300
+ export function globMatch(pattern: string, value: string): boolean {
301
+ let source = "";
302
+ for (let index = 0; index < pattern.length; index += 1) {
303
+ const character = pattern[index] ?? "";
304
+ if (character === "*") source += ".*";
305
+ else if (character === "?") source += ".";
306
+ else source += character.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
307
+ }
308
+ try {
309
+ return new RegExp(`^${source}$`, "s").test(value);
310
+ } catch {
311
+ return false;
312
+ }
313
+ }
314
+
315
+ export function ignored(id: string, patterns: readonly string[]): boolean {
316
+ return patterns.some((pattern) => globMatch(pattern, id));
317
+ }
318
+
319
+ const CAPABILITY_PATTERNS: ReadonlyArray<[string, RegExp]> = [
320
+ ["network", /\b(fetch\(|node:https?|from ["']https?["']|undici|WebSocket|axios)/],
321
+ ["filesystem", /\b(node:fs|from ["']fs["']|readFile|writeFile|readdir|mkdir)/],
322
+ ["process", /\b(child_process|spawn\(|execFile\(|exec\()/],
323
+ ["environment", /process\.env/],
324
+ ["dynamic-code", /\b(eval\(|new Function\()/],
325
+ ];
326
+
327
+ export function detectCapabilities(text: string): string[] {
328
+ return CAPABILITY_PATTERNS.filter(([, pattern]) => pattern.test(text)).map(([name]) => name);
329
+ }
330
+
331
+ const SUSPICIOUS_PATTERNS: ReadonlyArray<[string, RegExp]> = [
332
+ ["zero-width-characters", /[\u200B-\u200D\uFEFF]/],
333
+ ["bidi-controls", /[\u202A-\u202E\u2066-\u2069]/],
334
+ ["html-comment", /<!--/],
335
+ [
336
+ "ignore-instructions",
337
+ /\b(ignore|disregard)\s+(all\s+)?(previous|prior|above)\s+instructions?\b/i,
338
+ ],
339
+ ["do-not-tell", /\bdo\s+not\s+(tell|inform|mention)\b/i],
340
+ ["exfiltration-language", /\b(exfiltrate|send\s+.*\b(token|secret|credential|env)\b)/i],
341
+ ];
342
+
343
+ export function detectSuspicious(text: string): string[] {
344
+ return SUSPICIOUS_PATTERNS.filter(([, pattern]) => pattern.test(text)).map(([name]) => name);
345
+ }
346
+
347
+ export function truncate(text: string, max = 400): string {
348
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
349
+ }
package/src/v1.ts ADDED
@@ -0,0 +1,121 @@
1
+ import { homedir } from "node:os";
2
+ import { collectStaticAssets, describeAudit, filterAssets } from "./audit.ts";
3
+ import { audit, readLockfile } from "./lockfile.ts";
4
+ import { parseOptions } from "./options.ts";
5
+ import { changedMCPServers, changedSkills } from "./runtime.ts";
6
+ import type { AdapterEnvironment, AuditResult, Options } from "./types.ts";
7
+
8
+ type Json = Record<string, unknown>;
9
+
10
+ interface V1Hooks {
11
+ "tool.execute.before"?: (
12
+ input: { tool: string; sessionID: string; callID: string },
13
+ output: { args: unknown }
14
+ ) => Promise<void>;
15
+ "experimental.chat.system.transform"?: (
16
+ input: { sessionID?: string },
17
+ output: { system: string[] }
18
+ ) => Promise<void>;
19
+ event?: (input: { event: { type: string; properties?: Json } }) => Promise<void>;
20
+ dispose?: () => Promise<void>;
21
+ }
22
+
23
+ function object(value: unknown): Json {
24
+ return typeof value === "object" && value !== null && !Array.isArray(value)
25
+ ? (value as Json)
26
+ : {};
27
+ }
28
+
29
+ function serverForTool(tool: string, servers: readonly string[]): string | undefined {
30
+ return [...servers]
31
+ .sort((a, b) => b.length - a.length)
32
+ .find((server) => {
33
+ const normalized = server.replace(/[^A-Za-z0-9_-]/g, "_");
34
+ return tool.startsWith(`${normalized}_`) || tool.startsWith(`mcp__${normalized}__`);
35
+ });
36
+ }
37
+
38
+ function skillName(input: unknown): string | undefined {
39
+ const value = object(input);
40
+ return typeof value.name === "string"
41
+ ? value.name
42
+ : typeof value.id === "string"
43
+ ? value.id
44
+ : undefined;
45
+ }
46
+
47
+ export async function server(
48
+ input: unknown,
49
+ rawOptions?: unknown,
50
+ environment: AdapterEnvironment = {}
51
+ ): Promise<V1Hooks> {
52
+ const plugin = object(input);
53
+ const directory =
54
+ typeof plugin.directory === "string"
55
+ ? plugin.directory
56
+ : typeof plugin.worktree === "string"
57
+ ? plugin.worktree
58
+ : process.cwd();
59
+ const home = environment.home ?? homedir();
60
+ const options: Options = parseOptions(rawOptions, directory, home);
61
+ const staticAssets = await collectStaticAssets(directory, home, options);
62
+ const lock = await readLockfile(options.lockfile);
63
+ const result: AuditResult = audit(
64
+ lock === undefined ? undefined : filterAssets(lock.assets, options.include),
65
+ staticAssets.assets,
66
+ staticAssets.warnings,
67
+ false
68
+ );
69
+ const warned = new Set<string>();
70
+
71
+ const log = (message: string): void => console.error(`[opencode-integrity] ${message}`);
72
+
73
+ if (!result.locked) {
74
+ log(`no lockfile at ${options.lockfile}; static audit only (V1)`);
75
+ } else if (!result.ok) {
76
+ log(`drift detected in enforce=${options.mode === "enforce"} mode:\n${describeAudit(result)}`);
77
+ }
78
+
79
+ const hooks: V1Hooks = {
80
+ "tool.execute.before": async (event, output) => {
81
+ if (options.mode !== "enforce" || !result.locked) return;
82
+ const servers = changedMCPServers(result);
83
+ const server = serverForTool(event.tool, Object.keys(result.assets.mcp));
84
+ if (server !== undefined && servers.has(server)) {
85
+ throw new Error(`MCP server ${server} changed since opencode.lock.json; review and relock`);
86
+ }
87
+ if (event.tool === "skill") {
88
+ const name = skillName(output.args);
89
+ if (name !== undefined && changedSkills(result).has(name)) {
90
+ throw new Error(`skill ${name} changed since opencode.lock.json; review and relock`);
91
+ }
92
+ }
93
+ },
94
+
95
+ "experimental.chat.system.transform": async (event, output) => {
96
+ const sessionID = typeof event.sessionID === "string" ? event.sessionID : "";
97
+ if (sessionID.length === 0 || warned.has(sessionID)) return;
98
+ warned.add(sessionID);
99
+ if (!result.locked) {
100
+ output.system.push(
101
+ `opencode-integrity: no lockfile at ${options.lockfile}. Audit only on V1; run the CLI to create one.`
102
+ );
103
+ return;
104
+ }
105
+ if (!result.ok) {
106
+ output.system.push(`opencode-integrity: drift detected.\n${describeAudit(result, false)}`);
107
+ }
108
+ },
109
+
110
+ event: async ({ event }) => {
111
+ if (event.type === "session.created") {
112
+ if (!result.locked) log("session started without a lockfile");
113
+ else if (!result.ok) log(`session started with ${result.drift.length} drift entries`);
114
+ }
115
+ },
116
+
117
+ dispose: async () => undefined,
118
+ };
119
+
120
+ return hooks;
121
+ }