@ossdeveloper/github-compliance 1.0.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossdeveloper/github-compliance",
3
- "version": "1.0.1",
3
+ "version": "1.3.0",
4
4
  "description": "GitHub compliance plugin for OpenCode - enforces human approval for write operations",
5
5
  "main": "dist/plugin.js",
6
6
  "type": "module",
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
- type ToolInfo
23
+ normalizeToolName,
24
+ type ToolInfo
24
25
  } from "./compliance";
25
- import { buildBlockedError } from "./errors";
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
- * Intercepts GitHub write tools and validates compliance:
40
- * - Checks for existing compliance record in SQLite
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 - let everything else pass through
178
+ // Only intercept GitHub write tools
47
179
  if (!isGitHubWriteTool(input.tool)) {
48
180
  return;
49
181
  }
50
182
 
51
- let toolInfo: ToolInfo;
52
- let record: ComplianceRecord | null = null;
183
+ const isMcp = isMcpTool(input.tool);
53
184
 
54
- // Extract tool-specific information for compliance lookup
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
- toolInfo = extractToolInfo(input.tool, input.args as Record<string, unknown>);
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
- // Look up compliance record by tool, repo, and content hash
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
- // Validate the record (status, expiration, etc.)
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.id;
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
- repo: toolInfo.repo,
242
+ toolInfo: toolInfo.repo,
121
243
  reason: ERROR_REASONS.UNEXPECTED_ERROR,
122
244
  content_hash: toolInfo.contentHash,
123
- provided_record_id: record?.id || null,
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
- * Logs successful GitHub write operations to audit trail.
139
- * Only logs for GitHub write tools that were validated.
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 intercepted
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
- // Skip if no compliance record was attached (shouldn't happen)
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
- // Extract GitHub response data for audit
156
- const githubUrl = extractGitHubUrl(output.result);
157
- const githubId = extractGitHubId(output.result);
158
-
159
- // Log successful execution
160
- await logAudit({
161
- compliance_record_id: complianceRecordId,
162
- tool_name: toolInfo.toolName,
163
- owner: toolInfo.owner,
164
- repo: toolInfo.repo,
165
- github_username: "unknown",
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;
package/tools.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { tool } from "@opencode-ai/plugin";
10
+ import { z } from "zod";
10
11
  import type { Plugin } from "@opencode-ai/plugin";
11
12
  import {
12
13
  createComplianceRecord,
@@ -26,43 +27,54 @@ import {
26
27
  *
27
28
  * The tool records that human approval was obtained and creates
28
29
  * a valid compliance record that will allow the GitHub write operation.
30
+ *
31
+ * Note: body and title are base64 encoded to work around BunShell serialization
32
+ * issues with multiline strings. Caller should encode these values.
29
33
  */
30
34
  export const createComplianceRecordTool = tool({
31
- description: "Create a compliance record AFTER user approves the draft. MUST be called after explicit user approval. This records that human approval was obtained and allows the GitHub write operation to proceed.",
35
+ description: "Create compliance record",
32
36
  args: {
33
- tool_name: tool.schema.string({ description: "GitHub tool name (e.g., issue_write, pull_request_write)" }),
34
- owner: tool.schema.string({ description: "Repository owner (user or org)" }),
35
- repo: tool.schema.string({ description: "Repository name" }),
36
- title: tool.schema.string().optional({ description: "Issue/PR title" }),
37
- body: tool.schema.string({ description: "Issue/PR body content" }),
38
- github_username: tool.schema.string({ description: "GitHub username of human approver" }),
39
- approved_at: tool.schema.string({ description: "ISO8601 timestamp when user approved" }),
40
- checks: tool.schema.array(tool.schema.object({
41
- check_type: tool.schema.string({ description: "Type of check performed" }),
42
- passed: tool.schema.boolean({ description: "Whether check passed" }),
43
- details: tool.schema.record(tool.schema.string(), tool.schema.any()).optional({ description: "Additional details" })
44
- })).optional({ description: "List of compliance checks performed" })
37
+ tool_name: z.string(),
38
+ owner: z.string(),
39
+ repo: z.string(),
40
+ title: z.string().optional(),
41
+ body: z.string(),
42
+ github_username: z.string(),
43
+ approved_at: z.string(),
44
+ checks: z.string().optional()
45
45
  },
46
46
  async execute(args, _context) {
47
+ let checks: CreateRecordInput['checks'] = [];
48
+ if (args.checks) {
49
+ try {
50
+ checks = JSON.parse(args.checks);
51
+ } catch {
52
+ checks = [];
53
+ }
54
+ }
55
+
56
+ const body = Buffer.from(args.body, 'base64').toString('utf-8');
57
+ const title = args.title ? Buffer.from(args.title, 'base64').toString('utf-8') : null;
58
+
47
59
  const input: CreateRecordInput = {
48
60
  tool_name: args.tool_name,
49
61
  owner: args.owner,
50
62
  repo: args.repo,
51
- title: args.title || null,
52
- body: args.body,
63
+ title,
64
+ body,
53
65
  github_username: args.github_username,
54
66
  approved_at: args.approved_at,
55
- checks: args.checks || []
67
+ checks
56
68
  };
57
69
 
58
70
  const recordId = await createComplianceRecord(input);
59
71
 
60
- return {
72
+ return JSON.stringify({
61
73
  success: true,
62
74
  record_id: recordId,
63
- message: "Compliance record created. You may now execute the GitHub write operation.",
75
+ message: "Compliance record created",
64
76
  expires_at: new Date(Date.now() + 30 * 60 * 1000).toISOString()
65
- };
77
+ });
66
78
  }
67
79
  });
68
80
 
@@ -73,16 +85,16 @@ export const createComplianceRecordTool = tool({
73
85
  * before attempting a GitHub write operation.
74
86
  */
75
87
  export const getComplianceStatusTool = tool({
76
- description: "Check if a valid compliance record exists for a planned GitHub write operation.",
88
+ description: "Check compliance status",
77
89
  args: {
78
- tool_name: tool.schema.string({ description: "GitHub tool name" }),
79
- owner: tool.schema.string({ description: "Repository owner" }),
80
- repo: tool.schema.string({ description: "Repository name" }),
81
- content_hash: tool.schema.string({ description: "SHA256 hash of the content (format: sha256:...)" })
90
+ tool_name: z.string(),
91
+ owner: z.string(),
92
+ repo: z.string(),
93
+ content_hash: z.string()
82
94
  },
83
95
  async execute(args, _context) {
84
96
  const status = await getComplianceStatus(args.tool_name, args.owner, args.repo, args.content_hash);
85
- return status;
97
+ return JSON.stringify(status);
86
98
  }
87
99
  });
88
100