@ossdeveloper/github-compliance 1.0.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 +216 -0
- package/CHANGELOG.md +100 -0
- package/README.md +273 -0
- package/compliance.ts +159 -0
- package/database.ts +563 -0
- package/dist/plugin.js +12949 -0
- package/errors.ts +149 -0
- package/package.json +14 -0
- package/plugin.ts +177 -0
- package/schema.ts +88 -0
- package/tools.ts +95 -0
package/compliance.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compliance validation logic for GitHub write tools
|
|
3
|
+
*
|
|
4
|
+
* Handles tool identification, content hashing, and result parsing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createHash } from "crypto";
|
|
8
|
+
import { SCHEMA, RECORD_STATUS } from "./schema";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* List of GitHub write tools that require compliance validation
|
|
12
|
+
* Read-only tools are not included
|
|
13
|
+
*/
|
|
14
|
+
const GITHUB_WRITE_TOOLS_UNPREFIXED = [
|
|
15
|
+
"issue_write",
|
|
16
|
+
"issue_update",
|
|
17
|
+
"pull_request_write",
|
|
18
|
+
"pull_request_update",
|
|
19
|
+
"add_issue_comment",
|
|
20
|
+
"add_pull_request_comment",
|
|
21
|
+
"pull_request_review",
|
|
22
|
+
"pull_request_review_comment",
|
|
23
|
+
"create_pull_request",
|
|
24
|
+
"update_issue",
|
|
25
|
+
"update_pull_request",
|
|
26
|
+
"push_files",
|
|
27
|
+
"create_branch",
|
|
28
|
+
"create_or_update_file",
|
|
29
|
+
"delete_file",
|
|
30
|
+
"create_repository",
|
|
31
|
+
"create_pull_request_with_copilot",
|
|
32
|
+
"update_pull_request_branch",
|
|
33
|
+
"reply_to_pull_request_comment",
|
|
34
|
+
"add_reply_to_pull_request_comment"
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
const GITHUB_WRITE_TOOLS_PREFIXED = GITHUB_WRITE_TOOLS_UNPREFIXED.map(t => `github_${t}`);
|
|
38
|
+
|
|
39
|
+
export const GITHUB_WRITE_TOOLS = [...GITHUB_WRITE_TOOLS_UNPREFIXED, ...GITHUB_WRITE_TOOLS_PREFIXED] as const;
|
|
40
|
+
|
|
41
|
+
export type GitHubWriteTool = typeof GITHUB_WRITE_TOOLS[number];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Extracted information from a tool call for compliance lookup
|
|
45
|
+
*/
|
|
46
|
+
export interface ToolInfo {
|
|
47
|
+
toolName: string;
|
|
48
|
+
owner: string;
|
|
49
|
+
repo: string;
|
|
50
|
+
title: string | null;
|
|
51
|
+
body: string | null;
|
|
52
|
+
contentHash: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check if a tool name is a GitHub write tool requiring compliance
|
|
57
|
+
*
|
|
58
|
+
* @param toolName - Name of the tool to check
|
|
59
|
+
* @returns True if the tool requires compliance validation
|
|
60
|
+
*/
|
|
61
|
+
export function isGitHubWriteTool(toolName: string): boolean {
|
|
62
|
+
return GITHUB_WRITE_TOOLS.includes(toolName as GitHubWriteTool);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Extract compliance-relevant information from a tool call
|
|
67
|
+
*
|
|
68
|
+
* Different GitHub tools have different arguments, but they all share
|
|
69
|
+
* owner/repo and typically have title/body for content.
|
|
70
|
+
*
|
|
71
|
+
* @param toolName - Name of the tool being called
|
|
72
|
+
* @param args - Arguments passed to the tool
|
|
73
|
+
* @returns Extracted ToolInfo with content hash
|
|
74
|
+
*/
|
|
75
|
+
export function extractToolInfo(toolName: string, args: Record<string, unknown>): ToolInfo {
|
|
76
|
+
const owner = (args.owner as string) || "";
|
|
77
|
+
const repo = (args.repo as string) || "";
|
|
78
|
+
const title = (args.title as string) || null;
|
|
79
|
+
const body = (args.body as string) || null;
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
toolName,
|
|
83
|
+
owner,
|
|
84
|
+
repo,
|
|
85
|
+
title,
|
|
86
|
+
body,
|
|
87
|
+
contentHash: computeContentHash(title, body)
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Compute a SHA256 hash of the content for integrity verification
|
|
93
|
+
*
|
|
94
|
+
* The hash is computed over a normalized JSON string containing
|
|
95
|
+
* the content-determining fields. This ensures:
|
|
96
|
+
* - Same content = same hash
|
|
97
|
+
* - Different content = different hash
|
|
98
|
+
*
|
|
99
|
+
* @param title - Issue/PR title (or null)
|
|
100
|
+
* @param body - Issue/PR body (or null)
|
|
101
|
+
* @returns Hash string in format "sha256:..."
|
|
102
|
+
*/
|
|
103
|
+
export function computeContentHash(title: string | null, body: string | null): string {
|
|
104
|
+
const content = JSON.stringify({
|
|
105
|
+
title: title || "",
|
|
106
|
+
body: body || ""
|
|
107
|
+
});
|
|
108
|
+
return "sha256:" + createHash("sha256").update(content).digest("hex");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Extract GitHub issue/PR ID from a tool execution result
|
|
113
|
+
*
|
|
114
|
+
* GitHub MCP tools return results with 'number' or 'id' fields.
|
|
115
|
+
* This helper extracts the numeric/string ID for audit logging.
|
|
116
|
+
*
|
|
117
|
+
* @param result - Raw tool execution result
|
|
118
|
+
* @returns GitHub ID if found, null otherwise
|
|
119
|
+
*/
|
|
120
|
+
export function extractGitHubId(result: unknown): string | null {
|
|
121
|
+
if (!result || typeof result !== "object") return null;
|
|
122
|
+
|
|
123
|
+
const r = result as Record<string, unknown>;
|
|
124
|
+
|
|
125
|
+
if (typeof r.number === "number") {
|
|
126
|
+
return String(r.number);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (typeof r.id === "string" || typeof r.id === "number") {
|
|
130
|
+
return String(r.id);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Extract GitHub URL from a tool execution result
|
|
138
|
+
*
|
|
139
|
+
* GitHub MCP tools return results with 'html_url' or 'url' fields.
|
|
140
|
+
* This helper extracts the URL for audit logging.
|
|
141
|
+
*
|
|
142
|
+
* @param result - Raw tool execution result
|
|
143
|
+
* @returns GitHub URL if found, null otherwise
|
|
144
|
+
*/
|
|
145
|
+
export function extractGitHubUrl(result: unknown): string | null {
|
|
146
|
+
if (!result || typeof result !== "object") return null;
|
|
147
|
+
|
|
148
|
+
const r = result as Record<string, unknown>;
|
|
149
|
+
|
|
150
|
+
if (typeof r.html_url === "string") {
|
|
151
|
+
return r.html_url;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (typeof r.url === "string") {
|
|
155
|
+
return r.url;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return null;
|
|
159
|
+
}
|
package/database.ts
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database operations for GitHub Compliance Plugin
|
|
3
|
+
*
|
|
4
|
+
* Manages SQLite database for compliance records, audit logs, and failed attempts.
|
|
5
|
+
* Uses centralized schema from schema.ts for all table/column names.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import Database from "bun:sqlite";
|
|
9
|
+
import { createHash } from "crypto";
|
|
10
|
+
import { mkdir } from "fs";
|
|
11
|
+
import { homedir } from "os";
|
|
12
|
+
import {
|
|
13
|
+
SCHEMA,
|
|
14
|
+
RECORD_STATUS,
|
|
15
|
+
RECORD_TTL_MS,
|
|
16
|
+
RECORD_CLEANUP_AGE_MS,
|
|
17
|
+
CONTENT_PREVIEW_MAX_LENGTH
|
|
18
|
+
} from "./schema";
|
|
19
|
+
|
|
20
|
+
const DB_DIR = `${homedir()}/.opencode`;
|
|
21
|
+
const DB_PATH = `${DB_DIR}/github-compliance.db`;
|
|
22
|
+
|
|
23
|
+
let db: Database | null = null;
|
|
24
|
+
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// Types
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Compliance record stored in SQLite
|
|
31
|
+
* Represents approval for a specific GitHub write operation
|
|
32
|
+
*/
|
|
33
|
+
export interface ComplianceRecord {
|
|
34
|
+
id: string;
|
|
35
|
+
tool_name: string;
|
|
36
|
+
owner: string;
|
|
37
|
+
repo: string;
|
|
38
|
+
title: string | null;
|
|
39
|
+
content_hash: string;
|
|
40
|
+
github_username: string;
|
|
41
|
+
approved_at: string;
|
|
42
|
+
expires_at: string;
|
|
43
|
+
status: typeof RECORD_STATUS[keyof typeof RECORD_STATUS];
|
|
44
|
+
created_at: string;
|
|
45
|
+
client_name: string;
|
|
46
|
+
policy_version: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Individual compliance check performed
|
|
51
|
+
* e.g., CONTRIBUTING.md read, duplicate search, etc.
|
|
52
|
+
*/
|
|
53
|
+
export interface ComplianceCheck {
|
|
54
|
+
id: string;
|
|
55
|
+
compliance_record_id: string;
|
|
56
|
+
check_type: string;
|
|
57
|
+
check_passed: number;
|
|
58
|
+
details: string | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Audit log entry for successful operations
|
|
63
|
+
*/
|
|
64
|
+
export interface AuditEntry {
|
|
65
|
+
compliance_record_id: string | null;
|
|
66
|
+
tool_name: string;
|
|
67
|
+
owner: string;
|
|
68
|
+
repo: string;
|
|
69
|
+
github_username: string;
|
|
70
|
+
status: string;
|
|
71
|
+
error_message: string | null;
|
|
72
|
+
github_url: string | null;
|
|
73
|
+
github_id: string | null;
|
|
74
|
+
executed_at: string;
|
|
75
|
+
content_preview: string | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Failed attempt record for rejected operations
|
|
80
|
+
*/
|
|
81
|
+
export interface FailedAttemptEntry {
|
|
82
|
+
tool_name: string;
|
|
83
|
+
owner: string;
|
|
84
|
+
repo: string;
|
|
85
|
+
reason: string;
|
|
86
|
+
content_hash: string | null;
|
|
87
|
+
provided_record_id: string | null;
|
|
88
|
+
error_details: string | null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Input for creating a new compliance record
|
|
93
|
+
*/
|
|
94
|
+
export interface CreateRecordInput {
|
|
95
|
+
tool_name: string;
|
|
96
|
+
owner: string;
|
|
97
|
+
repo: string;
|
|
98
|
+
title: string | null;
|
|
99
|
+
body: string;
|
|
100
|
+
github_username: string;
|
|
101
|
+
approved_at: string;
|
|
102
|
+
checks: Array<{
|
|
103
|
+
check_type: string;
|
|
104
|
+
passed: boolean;
|
|
105
|
+
details: Record<string, unknown>;
|
|
106
|
+
}>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ============================================================================
|
|
110
|
+
// Helpers
|
|
111
|
+
// ============================================================================
|
|
112
|
+
|
|
113
|
+
function uuid(): string {
|
|
114
|
+
return crypto.randomUUID();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function computeContentHash(title: string | null, body: string): string {
|
|
118
|
+
const content = JSON.stringify({
|
|
119
|
+
title: title || "",
|
|
120
|
+
body: body || ""
|
|
121
|
+
});
|
|
122
|
+
return "sha256:" + createHash("sha256").update(content).digest("hex");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ============================================================================
|
|
126
|
+
// Database Initialization
|
|
127
|
+
// ============================================================================
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Initialize the SQLite database
|
|
131
|
+
*
|
|
132
|
+
* Creates the database file and tables if they don't exist.
|
|
133
|
+
* Tables: compliance_records, compliance_checks, audit_log, failed_attempts
|
|
134
|
+
*/
|
|
135
|
+
export async function initDatabase(): Promise<void> {
|
|
136
|
+
if (db) return;
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await mkdir(DB_DIR, { recursive: true });
|
|
140
|
+
} catch {
|
|
141
|
+
// Directory may already exist
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
db = new Database(DB_PATH);
|
|
145
|
+
db.run("PRAGMA foreign_keys = ON");
|
|
146
|
+
|
|
147
|
+
// Main compliance records table
|
|
148
|
+
db.run(`
|
|
149
|
+
CREATE TABLE IF NOT EXISTS ${SCHEMA.tables.COMPLIANCE_RECORDS} (
|
|
150
|
+
${SCHEMA.columns.ID} TEXT PRIMARY KEY,
|
|
151
|
+
${SCHEMA.columns.TOOL_NAME} TEXT NOT NULL,
|
|
152
|
+
${SCHEMA.columns.OWNER} TEXT NOT NULL,
|
|
153
|
+
${SCHEMA.columns.REPO} TEXT NOT NULL,
|
|
154
|
+
${SCHEMA.columns.TITLE} TEXT,
|
|
155
|
+
${SCHEMA.columns.CONTENT_HASH} TEXT NOT NULL,
|
|
156
|
+
${SCHEMA.columns.GITHUB_USERNAME} TEXT NOT NULL,
|
|
157
|
+
${SCHEMA.columns.APPROVED_AT} TEXT NOT NULL,
|
|
158
|
+
${SCHEMA.columns.EXPIRES_AT} TEXT NOT NULL,
|
|
159
|
+
${SCHEMA.columns.STATUS} TEXT DEFAULT '${RECORD_STATUS.PENDING}',
|
|
160
|
+
${SCHEMA.columns.CREATED_AT} TEXT NOT NULL,
|
|
161
|
+
${SCHEMA.columns.CLIENT_NAME} TEXT DEFAULT 'opencode',
|
|
162
|
+
${SCHEMA.columns.POLICY_VERSION} TEXT DEFAULT '1.0',
|
|
163
|
+
UNIQUE(${SCHEMA.columns.TOOL_NAME}, ${SCHEMA.columns.OWNER}, ${SCHEMA.columns.REPO}, ${SCHEMA.columns.CONTENT_HASH})
|
|
164
|
+
)
|
|
165
|
+
`);
|
|
166
|
+
|
|
167
|
+
// Individual checks performed for each record
|
|
168
|
+
db.run(`
|
|
169
|
+
CREATE TABLE IF NOT EXISTS ${SCHEMA.tables.COMPLIANCE_CHECKS} (
|
|
170
|
+
${SCHEMA.columns.ID} TEXT PRIMARY KEY,
|
|
171
|
+
${SCHEMA.columns.COMPLIANCE_RECORD_ID} TEXT NOT NULL,
|
|
172
|
+
${SCHEMA.columns.CHECK_TYPE} TEXT NOT NULL,
|
|
173
|
+
${SCHEMA.columns.CHECK_PASSED} INTEGER NOT NULL,
|
|
174
|
+
${SCHEMA.columns.DETAILS} TEXT,
|
|
175
|
+
FOREIGN KEY (${SCHEMA.columns.COMPLIANCE_RECORD_ID}) REFERENCES ${SCHEMA.tables.COMPLIANCE_RECORDS}(${SCHEMA.columns.ID}) ON DELETE CASCADE
|
|
176
|
+
)
|
|
177
|
+
`);
|
|
178
|
+
|
|
179
|
+
// Audit log of all successful operations
|
|
180
|
+
db.run(`
|
|
181
|
+
CREATE TABLE IF NOT EXISTS ${SCHEMA.tables.AUDIT_LOG} (
|
|
182
|
+
${SCHEMA.columns.ID} TEXT PRIMARY KEY,
|
|
183
|
+
${SCHEMA.columns.COMPLIANCE_RECORD_ID} TEXT,
|
|
184
|
+
${SCHEMA.columns.TOOL_NAME} TEXT NOT NULL,
|
|
185
|
+
${SCHEMA.columns.OWNER} TEXT NOT NULL,
|
|
186
|
+
${SCHEMA.columns.REPO} TEXT NOT NULL,
|
|
187
|
+
${SCHEMA.columns.GITHUB_USERNAME} TEXT NOT NULL,
|
|
188
|
+
${SCHEMA.columns.STATUS} TEXT NOT NULL,
|
|
189
|
+
${SCHEMA.columns.ERROR_MESSAGE} TEXT,
|
|
190
|
+
${SCHEMA.columns.GITHUB_URL} TEXT,
|
|
191
|
+
${SCHEMA.columns.GITHUB_ID} TEXT,
|
|
192
|
+
${SCHEMA.columns.EXECUTED_AT} TEXT NOT NULL,
|
|
193
|
+
${SCHEMA.columns.CLIENT_NAME} TEXT DEFAULT 'opencode',
|
|
194
|
+
${SCHEMA.columns.CONTENT_PREVIEW} TEXT
|
|
195
|
+
)
|
|
196
|
+
`);
|
|
197
|
+
|
|
198
|
+
// Failed attempts for debugging and analysis
|
|
199
|
+
db.run(`
|
|
200
|
+
CREATE TABLE IF NOT EXISTS ${SCHEMA.tables.FAILED_ATTEMPTS} (
|
|
201
|
+
${SCHEMA.columns.ID} TEXT PRIMARY KEY,
|
|
202
|
+
${SCHEMA.columns.TOOL_NAME} TEXT NOT NULL,
|
|
203
|
+
${SCHEMA.columns.OWNER} TEXT NOT NULL,
|
|
204
|
+
${SCHEMA.columns.REPO} TEXT NOT NULL,
|
|
205
|
+
${SCHEMA.columns.REASON} TEXT NOT NULL,
|
|
206
|
+
${SCHEMA.columns.CONTENT_HASH} TEXT,
|
|
207
|
+
${SCHEMA.columns.PROVIDED_RECORD_ID} TEXT,
|
|
208
|
+
${SCHEMA.columns.ATTEMPTED_AT} TEXT NOT NULL,
|
|
209
|
+
${SCHEMA.columns.GITHUB_USERNAME} TEXT,
|
|
210
|
+
${SCHEMA.columns.ERROR_DETAILS} TEXT
|
|
211
|
+
)
|
|
212
|
+
`);
|
|
213
|
+
|
|
214
|
+
// Indexes for performance
|
|
215
|
+
db.run(`CREATE INDEX IF NOT EXISTS ${SCHEMA.indexes.COMPLIANCE_LOOKUP} ON ${SCHEMA.tables.COMPLIANCE_RECORDS}(${SCHEMA.columns.TOOL_NAME}, ${SCHEMA.columns.OWNER}, ${SCHEMA.columns.REPO}, ${SCHEMA.columns.STATUS})`);
|
|
216
|
+
db.run(`CREATE INDEX IF NOT EXISTS ${SCHEMA.indexes.COMPLIANCE_EXPIRES} ON ${SCHEMA.tables.COMPLIANCE_RECORDS}(${SCHEMA.columns.EXPIRES_AT})`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Get the database instance, initializing if needed
|
|
221
|
+
*/
|
|
222
|
+
export async function getDatabase(): Promise<Database> {
|
|
223
|
+
if (!db) {
|
|
224
|
+
await initDatabase();
|
|
225
|
+
}
|
|
226
|
+
return db!;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ============================================================================
|
|
230
|
+
// Compliance Records
|
|
231
|
+
// ============================================================================
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Look up a compliance record by tool, repo, and content hash
|
|
235
|
+
*
|
|
236
|
+
* @param toolName - GitHub tool name (e.g., "issue_write")
|
|
237
|
+
* @param owner - Repository owner
|
|
238
|
+
* @param repo - Repository name
|
|
239
|
+
* @param contentHash - SHA256 hash of the content
|
|
240
|
+
* @returns Compliance record if found, null otherwise
|
|
241
|
+
*/
|
|
242
|
+
export async function getComplianceRecord(
|
|
243
|
+
toolName: string,
|
|
244
|
+
owner: string,
|
|
245
|
+
repo: string,
|
|
246
|
+
contentHash: string
|
|
247
|
+
): Promise<ComplianceRecord | null> {
|
|
248
|
+
const database = await getDatabase();
|
|
249
|
+
const stmt = database.prepare(`
|
|
250
|
+
SELECT * FROM ${SCHEMA.tables.COMPLIANCE_RECORDS}
|
|
251
|
+
WHERE ${SCHEMA.columns.TOOL_NAME} = ?
|
|
252
|
+
AND ${SCHEMA.columns.OWNER} = ?
|
|
253
|
+
AND ${SCHEMA.columns.REPO} = ?
|
|
254
|
+
AND ${SCHEMA.columns.CONTENT_HASH} = ?
|
|
255
|
+
`);
|
|
256
|
+
|
|
257
|
+
return stmt.get(toolName, owner, repo, contentHash) as ComplianceRecord | null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Validation result returned by validateCompliance
|
|
262
|
+
*/
|
|
263
|
+
export interface ValidationResult {
|
|
264
|
+
valid: boolean;
|
|
265
|
+
reason: string;
|
|
266
|
+
message: string;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Validate a compliance record for a tool call
|
|
271
|
+
*
|
|
272
|
+
* Checks:
|
|
273
|
+
* - Record exists
|
|
274
|
+
* - Status is 'approved' (not pending/used/expired)
|
|
275
|
+
* - Not expired (expires_at > now)
|
|
276
|
+
*
|
|
277
|
+
* @param record - Compliance record to validate (or null)
|
|
278
|
+
* @param toolInfo - Tool information for context
|
|
279
|
+
* @returns Validation result with reason if invalid
|
|
280
|
+
*/
|
|
281
|
+
export async function validateCompliance(
|
|
282
|
+
record: ComplianceRecord | null,
|
|
283
|
+
toolInfo: { toolName: string; owner: string; repo: string; contentHash: string }
|
|
284
|
+
): Promise<ValidationResult> {
|
|
285
|
+
if (!record) {
|
|
286
|
+
return {
|
|
287
|
+
valid: false,
|
|
288
|
+
reason: "NO_RECORD",
|
|
289
|
+
message: "No compliance record found for this operation."
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (record.status === RECORD_STATUS.PENDING) {
|
|
294
|
+
return {
|
|
295
|
+
valid: false,
|
|
296
|
+
reason: "NOT_APPROVED",
|
|
297
|
+
message: "Compliance record exists but has not been approved by user."
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (record.status === RECORD_STATUS.USED) {
|
|
302
|
+
return {
|
|
303
|
+
valid: false,
|
|
304
|
+
reason: "ALREADY_USED",
|
|
305
|
+
message: "This compliance record has already been used."
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (record.status === RECORD_STATUS.EXPIRED) {
|
|
310
|
+
return {
|
|
311
|
+
valid: false,
|
|
312
|
+
reason: "EXPIRED",
|
|
313
|
+
message: "Compliance record has expired."
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const now = new Date();
|
|
318
|
+
const expiresAt = new Date(record.expires_at);
|
|
319
|
+
|
|
320
|
+
if (now > expiresAt) {
|
|
321
|
+
return {
|
|
322
|
+
valid: false,
|
|
323
|
+
reason: "EXPIRED",
|
|
324
|
+
message: "Compliance record has expired."
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return { valid: true, reason: "", message: "" };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Mark a compliance record as 'used'
|
|
333
|
+
*
|
|
334
|
+
* Called after successful tool execution to prevent replay.
|
|
335
|
+
*
|
|
336
|
+
* @param recordId - ID of the record to mark as used
|
|
337
|
+
*/
|
|
338
|
+
export async function markRecordUsed(recordId: string): Promise<void> {
|
|
339
|
+
const database = await getDatabase();
|
|
340
|
+
const stmt = database.prepare(`
|
|
341
|
+
UPDATE ${SCHEMA.tables.COMPLIANCE_RECORDS}
|
|
342
|
+
SET ${SCHEMA.columns.STATUS} = '${RECORD_STATUS.USED}'
|
|
343
|
+
WHERE ${SCHEMA.columns.ID} = ?
|
|
344
|
+
`);
|
|
345
|
+
stmt.run(recordId);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Create a new compliance record after user approval
|
|
350
|
+
*
|
|
351
|
+
* Called by the LLM via create_compliance_record tool after
|
|
352
|
+
* the user explicitly approves the draft.
|
|
353
|
+
*
|
|
354
|
+
* @param data - Record data including checks performed
|
|
355
|
+
* @returns UUID of the created record
|
|
356
|
+
*/
|
|
357
|
+
export async function createComplianceRecord(data: CreateRecordInput): Promise<string> {
|
|
358
|
+
const database = await getDatabase();
|
|
359
|
+
const id = uuid();
|
|
360
|
+
const now = new Date();
|
|
361
|
+
const expiresAt = new Date(now.getTime() + RECORD_TTL_MS);
|
|
362
|
+
const contentHash = computeContentHash(data.title, data.body);
|
|
363
|
+
|
|
364
|
+
const stmt = database.prepare(`
|
|
365
|
+
INSERT INTO ${SCHEMA.tables.COMPLIANCE_RECORDS} (
|
|
366
|
+
${SCHEMA.columns.ID},
|
|
367
|
+
${SCHEMA.columns.TOOL_NAME},
|
|
368
|
+
${SCHEMA.columns.OWNER},
|
|
369
|
+
${SCHEMA.columns.REPO},
|
|
370
|
+
${SCHEMA.columns.TITLE},
|
|
371
|
+
${SCHEMA.columns.CONTENT_HASH},
|
|
372
|
+
${SCHEMA.columns.GITHUB_USERNAME},
|
|
373
|
+
${SCHEMA.columns.APPROVED_AT},
|
|
374
|
+
${SCHEMA.columns.EXPIRES_AT},
|
|
375
|
+
${SCHEMA.columns.STATUS},
|
|
376
|
+
${SCHEMA.columns.CREATED_AT}
|
|
377
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, '${RECORD_STATUS.APPROVED}', ?)
|
|
378
|
+
`);
|
|
379
|
+
|
|
380
|
+
stmt.run(
|
|
381
|
+
id,
|
|
382
|
+
data.tool_name,
|
|
383
|
+
data.owner,
|
|
384
|
+
data.repo,
|
|
385
|
+
data.title || null,
|
|
386
|
+
contentHash,
|
|
387
|
+
data.github_username,
|
|
388
|
+
data.approved_at,
|
|
389
|
+
expiresAt.toISOString(),
|
|
390
|
+
now.toISOString()
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
// Insert individual checks
|
|
394
|
+
for (const check of data.checks) {
|
|
395
|
+
const checkStmt = database.prepare(`
|
|
396
|
+
INSERT INTO ${SCHEMA.tables.COMPLIANCE_CHECKS} (
|
|
397
|
+
${SCHEMA.columns.ID},
|
|
398
|
+
${SCHEMA.columns.COMPLIANCE_RECORD_ID},
|
|
399
|
+
${SCHEMA.columns.CHECK_TYPE},
|
|
400
|
+
${SCHEMA.columns.CHECK_PASSED},
|
|
401
|
+
${SCHEMA.columns.DETAILS}
|
|
402
|
+
) VALUES (?, ?, ?, ?, ?)
|
|
403
|
+
`);
|
|
404
|
+
checkStmt.run(
|
|
405
|
+
uuid(),
|
|
406
|
+
id,
|
|
407
|
+
check.check_type,
|
|
408
|
+
check.passed ? 1 : 0,
|
|
409
|
+
JSON.stringify(check.details)
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return id;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Check if a valid compliance record exists
|
|
418
|
+
*
|
|
419
|
+
* @param toolName - GitHub tool name
|
|
420
|
+
* @param owner - Repository owner
|
|
421
|
+
* @param repo - Repository name
|
|
422
|
+
* @param contentHash - SHA256 hash of content
|
|
423
|
+
* @returns Status of any existing record
|
|
424
|
+
*/
|
|
425
|
+
export async function getComplianceStatus(
|
|
426
|
+
toolName: string,
|
|
427
|
+
owner: string,
|
|
428
|
+
repo: string,
|
|
429
|
+
contentHash: string
|
|
430
|
+
): Promise<{ exists: boolean; status: string | null; expires_at: string | null; approved_at: string | null }> {
|
|
431
|
+
const record = await getComplianceRecord(toolName, owner, repo, contentHash);
|
|
432
|
+
|
|
433
|
+
if (!record) {
|
|
434
|
+
return { exists: false, status: null, expires_at: null, approved_at: null };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
exists: true,
|
|
439
|
+
status: record.status,
|
|
440
|
+
expires_at: record.expires_at,
|
|
441
|
+
approved_at: record.approved_at
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ============================================================================
|
|
446
|
+
// Audit Logging
|
|
447
|
+
// ============================================================================
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Log a successful operation to the audit trail
|
|
451
|
+
*
|
|
452
|
+
* @param entry - Audit entry with operation details
|
|
453
|
+
*/
|
|
454
|
+
export async function logAudit(entry: AuditEntry): Promise<void> {
|
|
455
|
+
const database = await getDatabase();
|
|
456
|
+
const stmt = database.prepare(`
|
|
457
|
+
INSERT INTO ${SCHEMA.tables.AUDIT_LOG} (
|
|
458
|
+
${SCHEMA.columns.ID},
|
|
459
|
+
${SCHEMA.columns.COMPLIANCE_RECORD_ID},
|
|
460
|
+
${SCHEMA.columns.TOOL_NAME},
|
|
461
|
+
${SCHEMA.columns.OWNER},
|
|
462
|
+
${SCHEMA.columns.REPO},
|
|
463
|
+
${SCHEMA.columns.GITHUB_USERNAME},
|
|
464
|
+
${SCHEMA.columns.STATUS},
|
|
465
|
+
${SCHEMA.columns.ERROR_MESSAGE},
|
|
466
|
+
${SCHEMA.columns.GITHUB_URL},
|
|
467
|
+
${SCHEMA.columns.GITHUB_ID},
|
|
468
|
+
${SCHEMA.columns.EXECUTED_AT},
|
|
469
|
+
${SCHEMA.columns.CLIENT_NAME},
|
|
470
|
+
${SCHEMA.columns.CONTENT_PREVIEW}
|
|
471
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
472
|
+
`);
|
|
473
|
+
stmt.run(
|
|
474
|
+
uuid(),
|
|
475
|
+
entry.compliance_record_id,
|
|
476
|
+
entry.tool_name,
|
|
477
|
+
entry.owner,
|
|
478
|
+
entry.repo,
|
|
479
|
+
entry.github_username,
|
|
480
|
+
entry.status,
|
|
481
|
+
entry.error_message || null,
|
|
482
|
+
entry.github_url || null,
|
|
483
|
+
entry.github_id || null,
|
|
484
|
+
entry.executed_at,
|
|
485
|
+
"opencode",
|
|
486
|
+
entry.content_preview ? entry.content_preview.substring(0, CONTENT_PREVIEW_MAX_LENGTH) : null
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Log a failed/rejected attempt for debugging and analysis
|
|
492
|
+
*
|
|
493
|
+
* @param entry - Failed attempt entry with rejection reason
|
|
494
|
+
*/
|
|
495
|
+
export async function logFailedAttempt(entry: FailedAttemptEntry): Promise<void> {
|
|
496
|
+
const database = await getDatabase();
|
|
497
|
+
const stmt = database.prepare(`
|
|
498
|
+
INSERT INTO ${SCHEMA.tables.FAILED_ATTEMPTS} (
|
|
499
|
+
${SCHEMA.columns.ID},
|
|
500
|
+
${SCHEMA.columns.TOOL_NAME},
|
|
501
|
+
${SCHEMA.columns.OWNER},
|
|
502
|
+
${SCHEMA.columns.REPO},
|
|
503
|
+
${SCHEMA.columns.REASON},
|
|
504
|
+
${SCHEMA.columns.CONTENT_HASH},
|
|
505
|
+
${SCHEMA.columns.PROVIDED_RECORD_ID},
|
|
506
|
+
${SCHEMA.columns.ATTEMPTED_AT},
|
|
507
|
+
${SCHEMA.columns.GITHUB_USERNAME},
|
|
508
|
+
${SCHEMA.columns.ERROR_DETAILS}
|
|
509
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
510
|
+
`);
|
|
511
|
+
stmt.run(
|
|
512
|
+
uuid(),
|
|
513
|
+
entry.tool_name,
|
|
514
|
+
entry.owner,
|
|
515
|
+
entry.repo,
|
|
516
|
+
entry.reason,
|
|
517
|
+
entry.content_hash || null,
|
|
518
|
+
entry.provided_record_id || null,
|
|
519
|
+
new Date().toISOString(),
|
|
520
|
+
null,
|
|
521
|
+
entry.error_details || null
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ============================================================================
|
|
526
|
+
// Cleanup
|
|
527
|
+
// ============================================================================
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Mark all expired records as 'expired'
|
|
531
|
+
* Should be called periodically (e.g., on startup)
|
|
532
|
+
*
|
|
533
|
+
* @returns Number of records marked as expired
|
|
534
|
+
*/
|
|
535
|
+
export async function cleanupExpiredRecords(): Promise<number> {
|
|
536
|
+
const database = await getDatabase();
|
|
537
|
+
const now = new Date().toISOString();
|
|
538
|
+
const stmt = database.prepare(`
|
|
539
|
+
UPDATE ${SCHEMA.tables.COMPLIANCE_RECORDS}
|
|
540
|
+
SET ${SCHEMA.columns.STATUS} = '${RECORD_STATUS.EXPIRED}'
|
|
541
|
+
WHERE ${SCHEMA.columns.EXPIRES_AT} < ?
|
|
542
|
+
AND ${SCHEMA.columns.STATUS} = '${RECORD_STATUS.APPROVED}'
|
|
543
|
+
`);
|
|
544
|
+
const result = stmt.run(now);
|
|
545
|
+
return result.changes;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Delete old records (older than 7 days)
|
|
550
|
+
* Cleanup job for maintenance
|
|
551
|
+
*
|
|
552
|
+
* @returns Number of records deleted
|
|
553
|
+
*/
|
|
554
|
+
export async function cleanupOldRecords(): Promise<number> {
|
|
555
|
+
const database = await getDatabase();
|
|
556
|
+
const cutoff = new Date(Date.now() - RECORD_CLEANUP_AGE_MS).toISOString();
|
|
557
|
+
const stmt = database.prepare(`
|
|
558
|
+
DELETE FROM ${SCHEMA.tables.COMPLIANCE_RECORDS}
|
|
559
|
+
WHERE ${SCHEMA.columns.EXPIRES_AT} < ?
|
|
560
|
+
`);
|
|
561
|
+
const result = stmt.run(cutoff);
|
|
562
|
+
return result.changes;
|
|
563
|
+
}
|