@ossdeveloper/github-compliance 1.0.2 → 1.3.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/ARCHITECTURE.md +150 -60
- package/CHANGELOG.md +28 -1
- package/README.md +64 -15
- package/compliance.ts +22 -37
- package/contracts.ts +137 -0
- package/database.ts +2 -9
- package/dist/plugin.js +194 -61
- package/package.json +1 -1
- package/plugin.ts +249 -68
package/plugin.ts
CHANGED
|
@@ -8,23 +8,158 @@
|
|
|
8
8
|
import type { Plugin } from "@opencode-ai/plugin";
|
|
9
9
|
import {
|
|
10
10
|
initDatabase,
|
|
11
|
-
getComplianceRecord,
|
|
11
|
+
getComplianceRecord,
|
|
12
12
|
validateCompliance,
|
|
13
13
|
markRecordUsed,
|
|
14
14
|
logAudit,
|
|
15
15
|
logFailedAttempt,
|
|
16
|
-
type ComplianceRecord
|
|
16
|
+
type ComplianceRecord,
|
|
17
|
+
type ValidationResult
|
|
17
18
|
} from "./database";
|
|
18
19
|
import {
|
|
19
|
-
isGitHubWriteTool,
|
|
20
|
-
extractToolInfo,
|
|
20
|
+
isGitHubWriteTool,
|
|
21
21
|
extractGitHubUrl,
|
|
22
22
|
extractGitHubId,
|
|
23
|
-
|
|
23
|
+
normalizeToolName,
|
|
24
|
+
type ToolInfo
|
|
24
25
|
} from "./compliance";
|
|
25
|
-
import {
|
|
26
|
+
import { extractRecordFields, computeContentHash, type ComplianceRecordFields } from "./contracts";
|
|
27
|
+
import { buildBlockedError, type BlockedError } from "./errors";
|
|
26
28
|
import { tools } from "./tools";
|
|
27
|
-
import { ERROR_REASONS } from "./schema";
|
|
29
|
+
import { ERROR_REASONS, SCHEMA } from "./schema";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Check if this is an MCP tool (has github_ prefix)
|
|
33
|
+
* MCP tools don't have args populated in tool.execute.before
|
|
34
|
+
*/
|
|
35
|
+
function isMcpTool(toolName: string): boolean {
|
|
36
|
+
return toolName.startsWith("github_");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Extract tool info from args, handling the case where args might be missing
|
|
41
|
+
*
|
|
42
|
+
* For MCP tools in tool.execute.before, args are not available.
|
|
43
|
+
* For other tools, args should be in output.args.
|
|
44
|
+
*/
|
|
45
|
+
function extractToolInfoFromArgs(
|
|
46
|
+
toolName: string,
|
|
47
|
+
args: Record<string, unknown> | null | undefined
|
|
48
|
+
): ToolInfo {
|
|
49
|
+
const fields = extractRecordFields(toolName, args);
|
|
50
|
+
return {
|
|
51
|
+
toolName: fields.toolName,
|
|
52
|
+
owner: fields.owner,
|
|
53
|
+
repo: fields.repo,
|
|
54
|
+
title: fields.title,
|
|
55
|
+
body: fields.body,
|
|
56
|
+
contentHash: fields.contentHash
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Attempt to extract args from various sources in the input/output
|
|
62
|
+
*/
|
|
63
|
+
function extractArgs(input: Record<string, unknown>, output: Record<string, unknown>): Record<string, unknown> | null {
|
|
64
|
+
// Try output.args first (works for non-MCP tools)
|
|
65
|
+
if (output.args && typeof output.args === 'object') {
|
|
66
|
+
return output.args as Record<string, unknown>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Try input.arguments (sometimes populated)
|
|
70
|
+
if (input.arguments && typeof input.arguments === 'object') {
|
|
71
|
+
return input.arguments as Record<string, unknown>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Try input.args
|
|
75
|
+
if (input.args && typeof input.args === 'object') {
|
|
76
|
+
return input.args as Record<string, unknown>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// For MCP tools, args might be stringified in a different field
|
|
80
|
+
if (typeof input.arguments === 'string') {
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(input.arguments);
|
|
83
|
+
} catch {
|
|
84
|
+
// Not JSON
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Validate compliance for a GitHub write operation
|
|
93
|
+
*/
|
|
94
|
+
async function validateAndProcess(
|
|
95
|
+
toolName: string,
|
|
96
|
+
owner: string,
|
|
97
|
+
repo: string,
|
|
98
|
+
contentHash: string,
|
|
99
|
+
toolInfo: ToolInfo
|
|
100
|
+
): Promise<{ allowed: boolean; record: ComplianceRecord | null; validation: ValidationResult }> {
|
|
101
|
+
// Look up compliance record by tool, repo, and content hash
|
|
102
|
+
const record = await getComplianceRecord(
|
|
103
|
+
toolName,
|
|
104
|
+
owner,
|
|
105
|
+
repo,
|
|
106
|
+
contentHash
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Validate the record (status, expiration, etc.)
|
|
110
|
+
const validation = await validateCompliance(record, {
|
|
111
|
+
toolName,
|
|
112
|
+
owner,
|
|
113
|
+
repo,
|
|
114
|
+
contentHash
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (!validation.valid) {
|
|
118
|
+
// Log rejected attempt for audit
|
|
119
|
+
await logFailedAttempt({
|
|
120
|
+
tool_name: toolName,
|
|
121
|
+
owner,
|
|
122
|
+
repo,
|
|
123
|
+
reason: validation.reason,
|
|
124
|
+
content_hash: contentHash,
|
|
125
|
+
provided_record_id: record?.id || null,
|
|
126
|
+
error_details: validation.message
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return { allowed: false, record, validation };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Record is valid - mark as used to prevent replay
|
|
133
|
+
await markRecordUsed(record.id);
|
|
134
|
+
|
|
135
|
+
return { allowed: true, record, validation };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Log successful execution
|
|
140
|
+
*/
|
|
141
|
+
async function logSuccess(
|
|
142
|
+
toolInfo: ToolInfo,
|
|
143
|
+
record: ComplianceRecord,
|
|
144
|
+
result: unknown
|
|
145
|
+
): Promise<void> {
|
|
146
|
+
const githubUrl = extractGitHubUrl(result);
|
|
147
|
+
const githubId = extractGitHubId(result);
|
|
148
|
+
|
|
149
|
+
await logAudit({
|
|
150
|
+
compliance_record_id: record.id,
|
|
151
|
+
tool_name: toolInfo.toolName,
|
|
152
|
+
owner: toolInfo.owner,
|
|
153
|
+
repo: toolInfo.repo,
|
|
154
|
+
github_username: record.github_username,
|
|
155
|
+
status: "success",
|
|
156
|
+
error_message: null,
|
|
157
|
+
github_url: githubUrl,
|
|
158
|
+
github_id: githubId,
|
|
159
|
+
executed_at: new Date().toISOString(),
|
|
160
|
+
content_preview: toolInfo.body ? toolInfo.body.substring(0, 500) : null
|
|
161
|
+
});
|
|
162
|
+
}
|
|
28
163
|
|
|
29
164
|
export const GitHubCompliancePlugin: Plugin = async (_ctx) => {
|
|
30
165
|
await initDatabase();
|
|
@@ -36,26 +171,33 @@ export const GitHubCompliancePlugin: Plugin = async (_ctx) => {
|
|
|
36
171
|
/**
|
|
37
172
|
* Pre-execution hook for tool calls
|
|
38
173
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* - Validates record status (approved, not expired, not used)
|
|
42
|
-
* - Blocks tool execution if validation fails
|
|
43
|
-
* - Marks record as 'used' if validation succeeds
|
|
174
|
+
* For non-MCP tools: Validates compliance and blocks if invalid
|
|
175
|
+
* For MCP tools: Cannot validate (args not available), lets call proceed
|
|
44
176
|
*/
|
|
45
177
|
"tool.execute.before": async (input, output) => {
|
|
46
|
-
// Only intercept GitHub write tools
|
|
178
|
+
// Only intercept GitHub write tools
|
|
47
179
|
if (!isGitHubWriteTool(input.tool)) {
|
|
48
180
|
return;
|
|
49
181
|
}
|
|
50
182
|
|
|
51
|
-
|
|
52
|
-
let record: ComplianceRecord | null = null;
|
|
183
|
+
const isMcp = isMcpTool(input.tool);
|
|
53
184
|
|
|
54
|
-
//
|
|
185
|
+
// For MCP tools, we cannot get args in before hook - skip validation
|
|
186
|
+
// The after hook will handle validation for MCP tools
|
|
187
|
+
if (isMcp) {
|
|
188
|
+
// Store a marker that this MCP tool needs post-execution validation
|
|
189
|
+
(output as Record<string, unknown>).__needsMcpValidation = true;
|
|
190
|
+
(output as Record<string, unknown>).__mcpToolName = input.tool;
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// For non-MCP tools, validate as before
|
|
195
|
+
let toolInfo: ToolInfo;
|
|
196
|
+
|
|
55
197
|
try {
|
|
56
|
-
|
|
198
|
+
const args = extractArgs(input as Record<string, unknown>, output as Record<string, unknown>);
|
|
199
|
+
toolInfo = extractToolInfoFromArgs(input.tool, args);
|
|
57
200
|
} catch (error) {
|
|
58
|
-
// Log extraction failures for debugging
|
|
59
201
|
await logFailedAttempt({
|
|
60
202
|
tool_name: input.tool,
|
|
61
203
|
owner: "unknown",
|
|
@@ -73,54 +215,34 @@ export const GitHubCompliancePlugin: Plugin = async (_ctx) => {
|
|
|
73
215
|
}
|
|
74
216
|
|
|
75
217
|
try {
|
|
76
|
-
|
|
77
|
-
record = await getComplianceRecord(
|
|
218
|
+
const { allowed, record, validation } = await validateAndProcess(
|
|
78
219
|
toolInfo.toolName,
|
|
79
220
|
toolInfo.owner,
|
|
80
221
|
toolInfo.repo,
|
|
81
|
-
toolInfo.contentHash
|
|
222
|
+
toolInfo.contentHash,
|
|
223
|
+
toolInfo
|
|
82
224
|
);
|
|
83
225
|
|
|
84
|
-
|
|
85
|
-
const validation = await validateCompliance(record, toolInfo);
|
|
86
|
-
|
|
87
|
-
if (!validation.valid) {
|
|
88
|
-
// Log rejected attempt for audit
|
|
89
|
-
await logFailedAttempt({
|
|
90
|
-
tool_name: toolInfo.toolName,
|
|
91
|
-
owner: toolInfo.owner,
|
|
92
|
-
repo: toolInfo.repo,
|
|
93
|
-
reason: validation.reason,
|
|
94
|
-
content_hash: toolInfo.contentHash,
|
|
95
|
-
provided_record_id: record?.id || null,
|
|
96
|
-
error_details: validation.message
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
// Block the tool execution with detailed error message
|
|
226
|
+
if (!allowed) {
|
|
100
227
|
throw new Error(buildBlockedError(validation));
|
|
101
228
|
}
|
|
102
229
|
|
|
103
|
-
// Record is valid - mark as used to prevent replay
|
|
104
|
-
await markRecordUsed(record.id);
|
|
105
|
-
|
|
106
230
|
// Attach metadata for after hook
|
|
107
|
-
(output as Record<string, unknown>).__complianceRecordId = record
|
|
231
|
+
(output as Record<string, unknown>).__complianceRecordId = record!.id;
|
|
108
232
|
(output as Record<string, unknown>).__toolInfo = toolInfo;
|
|
109
233
|
|
|
110
234
|
} catch (error) {
|
|
111
|
-
// Re-throw blocked errors as-is
|
|
112
235
|
if (error instanceof Error && error.message.includes("[GITHUB COMPLIANCE BLOCKED]")) {
|
|
113
236
|
throw error;
|
|
114
237
|
}
|
|
115
238
|
|
|
116
|
-
// Unexpected errors - log and block
|
|
117
239
|
await logFailedAttempt({
|
|
118
240
|
tool_name: toolInfo.toolName,
|
|
119
241
|
owner: toolInfo.owner,
|
|
120
|
-
|
|
242
|
+
toolInfo: toolInfo.repo,
|
|
121
243
|
reason: ERROR_REASONS.UNEXPECTED_ERROR,
|
|
122
244
|
content_hash: toolInfo.contentHash,
|
|
123
|
-
provided_record_id:
|
|
245
|
+
provided_record_id: null,
|
|
124
246
|
error_details: error instanceof Error ? error.message : String(error)
|
|
125
247
|
});
|
|
126
248
|
|
|
@@ -135,16 +257,82 @@ export const GitHubCompliancePlugin: Plugin = async (_ctx) => {
|
|
|
135
257
|
/**
|
|
136
258
|
* Post-execution hook for successful tool calls
|
|
137
259
|
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
260
|
+
* For MCP tools: Performs compliance validation here (args available in input)
|
|
261
|
+
* For non-MCP tools: Logs successful execution
|
|
140
262
|
*/
|
|
141
263
|
"tool.execute.after": async (input, output) => {
|
|
142
|
-
// Only process GitHub write tools we
|
|
264
|
+
// Only process GitHub write tools we might have skipped in before
|
|
143
265
|
if (!isGitHubWriteTool(input.tool)) {
|
|
144
266
|
return;
|
|
145
267
|
}
|
|
146
268
|
|
|
147
|
-
|
|
269
|
+
const isMcp = isMcpTool(input.tool);
|
|
270
|
+
const needsMcpValidation = (output as Record<string, unknown>).__needsMcpValidation === true;
|
|
271
|
+
|
|
272
|
+
// For MCP tools that weren't validated in before hook
|
|
273
|
+
if (isMcp && needsMcpValidation) {
|
|
274
|
+
let toolInfo: ToolInfo;
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
// Args ARE available in input for after hook
|
|
278
|
+
const args = input.args as Record<string, unknown> | null;
|
|
279
|
+
toolInfo = extractToolInfoFromArgs(input.tool, args || null);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
// Log extraction failure but don't block - tool already executed
|
|
282
|
+
await logFailedAttempt({
|
|
283
|
+
tool_name: input.tool,
|
|
284
|
+
owner: "unknown",
|
|
285
|
+
repo: "unknown",
|
|
286
|
+
reason: ERROR_REASONS.EXTRACTION_ERROR,
|
|
287
|
+
content_hash: null,
|
|
288
|
+
error_details: `Failed to extract MCP tool info in after hook: ${error instanceof Error ? error.message : String(error)}`
|
|
289
|
+
});
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const { allowed, record, validation } = await validateAndProcess(
|
|
295
|
+
toolInfo.toolName,
|
|
296
|
+
toolInfo.owner,
|
|
297
|
+
toolInfo.repo,
|
|
298
|
+
toolInfo.contentHash,
|
|
299
|
+
toolInfo
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
if (!allowed) {
|
|
303
|
+
// Tool already executed, log the violation
|
|
304
|
+
await logFailedAttempt({
|
|
305
|
+
tool_name: toolInfo.toolName,
|
|
306
|
+
owner: toolInfo.owner,
|
|
307
|
+
repo: toolInfo.repo,
|
|
308
|
+
reason: validation.reason,
|
|
309
|
+
content_hash: toolInfo.contentHash,
|
|
310
|
+
provided_record_id: record?.id || null,
|
|
311
|
+
error_details: "MCP tool executed without valid compliance record"
|
|
312
|
+
});
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Valid - log success
|
|
317
|
+
if (record) {
|
|
318
|
+
await logSuccess(toolInfo, record, output.result);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
} catch (error) {
|
|
322
|
+
await logFailedAttempt({
|
|
323
|
+
tool_name: toolInfo.toolName,
|
|
324
|
+
owner: toolInfo.owner,
|
|
325
|
+
repo: toolInfo.repo,
|
|
326
|
+
reason: ERROR_REASONS.UNEXPECTED_ERROR,
|
|
327
|
+
content_hash: toolInfo.contentHash,
|
|
328
|
+
provided_record_id: null,
|
|
329
|
+
error_details: error instanceof Error ? error.message : String(error)
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// For non-MCP tools - log successful execution
|
|
148
336
|
const complianceRecordId = (output as Record<string, unknown>).__complianceRecordId as string | undefined;
|
|
149
337
|
const toolInfo = (output as Record<string, unknown>).__toolInfo as ToolInfo | undefined;
|
|
150
338
|
|
|
@@ -152,26 +340,19 @@ export const GitHubCompliancePlugin: Plugin = async (_ctx) => {
|
|
|
152
340
|
return;
|
|
153
341
|
}
|
|
154
342
|
|
|
155
|
-
//
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
status: "success",
|
|
167
|
-
error_message: null,
|
|
168
|
-
github_url: githubUrl,
|
|
169
|
-
github_id: githubId,
|
|
170
|
-
executed_at: new Date().toISOString(),
|
|
171
|
-
content_preview: toolInfo.body ? toolInfo.body.substring(0, 500) : null
|
|
172
|
-
});
|
|
343
|
+
// Get the record from DB to log with proper username
|
|
344
|
+
const record = await getComplianceRecord(
|
|
345
|
+
toolInfo.toolName,
|
|
346
|
+
toolInfo.owner,
|
|
347
|
+
toolInfo.repo,
|
|
348
|
+
toolInfo.contentHash
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
if (record) {
|
|
352
|
+
await logSuccess(toolInfo, record, output.result);
|
|
353
|
+
}
|
|
173
354
|
}
|
|
174
355
|
};
|
|
175
356
|
};
|
|
176
357
|
|
|
177
|
-
export default GitHubCompliancePlugin;
|
|
358
|
+
export default GitHubCompliancePlugin;
|