@syntesseraai/opencode-feature-factory 0.2.3 → 0.2.4

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.
@@ -0,0 +1,378 @@
1
+ const SERVICE_NAME = 'feature-factory';
2
+ const IDLE_DEBOUNCE_MS = 500;
3
+ const CI_TIMEOUT_MS = 300000; // 5 minutes
4
+ const SESSION_TTL_MS = 3600000; // 1 hour
5
+ const CLEANUP_INTERVAL_MS = 600000; // 10 minutes
6
+ export const SECRET_PATTERNS = [
7
+ // AWS Access Key IDs
8
+ { pattern: /AKIA[0-9A-Z]{16}/g, replacement: '[REDACTED_AWS_KEY]' },
9
+ // GitHub Personal Access Tokens (classic)
10
+ { pattern: /ghp_[A-Za-z0-9]{36}/g, replacement: '[REDACTED_GH_TOKEN]' },
11
+ // GitHub Personal Access Tokens (fine-grained)
12
+ { pattern: /github_pat_[A-Za-z0-9_]{22,}/g, replacement: '[REDACTED_GH_TOKEN]' },
13
+ // GitHub OAuth tokens
14
+ { pattern: /gho_[A-Za-z0-9]{36}/g, replacement: '[REDACTED_GH_TOKEN]' },
15
+ // GitHub App tokens (user-to-server and server-to-server)
16
+ { pattern: /ghu_[A-Za-z0-9]{36}/g, replacement: '[REDACTED_GH_TOKEN]' },
17
+ { pattern: /ghs_[A-Za-z0-9]{36}/g, replacement: '[REDACTED_GH_TOKEN]' },
18
+ // GitLab Personal Access Tokens
19
+ { pattern: /glpat-[A-Za-z0-9-]{20,}/g, replacement: '[REDACTED_GITLAB_TOKEN]' },
20
+ // npm tokens
21
+ { pattern: /npm_[A-Za-z0-9]{36}/g, replacement: '[REDACTED_NPM_TOKEN]' },
22
+ // Slack bot tokens
23
+ { pattern: /xoxb-[0-9A-Za-z-]+/g, replacement: '[REDACTED_SLACK_TOKEN]' },
24
+ // Slack user tokens
25
+ { pattern: /xoxp-[0-9A-Za-z-]+/g, replacement: '[REDACTED_SLACK_TOKEN]' },
26
+ // Slack app tokens
27
+ { pattern: /xapp-[0-9A-Za-z-]+/g, replacement: '[REDACTED_SLACK_TOKEN]' },
28
+ // Slack webhook URLs
29
+ { pattern: /hooks\.slack\.com\/services\/[A-Z0-9/]+/g, replacement: '[REDACTED_SLACK_WEBHOOK]' },
30
+ // Stripe live secret keys
31
+ { pattern: /sk_live_[0-9a-zA-Z]{24,}/g, replacement: '[REDACTED_STRIPE_KEY]' },
32
+ // Stripe test secret keys
33
+ { pattern: /sk_test_[0-9a-zA-Z]{24,}/g, replacement: '[REDACTED_STRIPE_KEY]' },
34
+ // Stripe live restricted keys
35
+ { pattern: /rk_live_[0-9a-zA-Z]{24,}/g, replacement: '[REDACTED_STRIPE_KEY]' },
36
+ // Stripe test restricted keys
37
+ { pattern: /rk_test_[0-9a-zA-Z]{24,}/g, replacement: '[REDACTED_STRIPE_KEY]' },
38
+ // Bearer tokens
39
+ { pattern: /Bearer\s+[\w\-.]+/gi, replacement: 'Bearer [REDACTED]' },
40
+ // API keys (api_key, api-key, apikey, apikeys, etc.)
41
+ { pattern: /api[_-]?keys?[=:\s]+['"]?[\w-]+['"]?/gi, replacement: 'api_key=[REDACTED]' },
42
+ // Tokens (token, tokens) - require minimum 8 char value to reduce false positives
43
+ { pattern: /tokens?[=:\s]+['"]?([A-Za-z0-9_-]{8,})['"]?/gi, replacement: 'token=[REDACTED]' },
44
+ // Passwords
45
+ { pattern: /passwords?[=:\s]+['"]?[^\s'"]+['"]?/gi, replacement: 'password=[REDACTED]' },
46
+ // Generic secrets
47
+ { pattern: /secrets?[=:\s]+['"]?[^\s'"]+['"]?/gi, replacement: 'secret=[REDACTED]' },
48
+ // Base64-encoded long strings that look like secrets (40-500 chars to prevent ReDoS)
49
+ { pattern: /[A-Za-z0-9+/]{40,500}={0,2}/g, replacement: '[REDACTED_BASE64]' },
50
+ // Private keys (RSA, DSA, EC, OpenSSH, etc.)
51
+ {
52
+ pattern: /-----BEGIN[\s\w]+PRIVATE KEY-----[\s\S]*?-----END[\s\w]+PRIVATE KEY-----/g,
53
+ replacement: '[REDACTED_PRIVATE_KEY]',
54
+ },
55
+ // Database connection strings with credentials (postgres, postgresql, mysql, mongodb, redis)
56
+ // Password portion handles URL-encoded characters like %40 (for @) and %23 (for #)
57
+ {
58
+ pattern: /(postgres|postgresql|mysql|mongodb(\+srv)?|rediss?):\/\/[^\s/:]+:(?:[^@\s]|%[0-9A-Fa-f]{2})+@[^\s]+/gi,
59
+ replacement: '[REDACTED_CONNECTION_STRING]',
60
+ },
61
+ // GCP API keys
62
+ { pattern: /AIza[0-9A-Za-z_-]{35}/g, replacement: '[REDACTED_GCP_KEY]' },
63
+ // GCP OAuth tokens
64
+ { pattern: /ya29\.[0-9A-Za-z_-]+/g, replacement: '[REDACTED_GCP_TOKEN]' },
65
+ ];
66
+ /**
67
+ * Sanitizes CI output by redacting common secret patterns before sending to the LLM.
68
+ * This helps prevent accidental exposure of sensitive information in prompts.
69
+ */
70
+ export function sanitizeOutput(output) {
71
+ let sanitized = output;
72
+ for (const { pattern, replacement } of SECRET_PATTERNS) {
73
+ sanitized = sanitized.replace(pattern, replacement);
74
+ }
75
+ return sanitized;
76
+ }
77
+ /**
78
+ * Truncates CI output to the last N lines to reduce prompt size and focus on relevant errors.
79
+ * Adds a header indicating truncation if the output was longer than the limit.
80
+ */
81
+ export function truncateOutput(output, maxLines = 20) {
82
+ const lines = output.split('\n');
83
+ if (lines.length <= maxLines) {
84
+ return output;
85
+ }
86
+ const truncatedLines = lines.slice(-maxLines);
87
+ const omittedCount = lines.length - maxLines;
88
+ return `... (${omittedCount} lines omitted)\n${truncatedLines.join('\n')}`;
89
+ }
90
+ const sessions = new Map();
91
+ let cleanupIntervalId = null;
92
+ /**
93
+ * Removes sessions that haven't been accessed within the TTL period.
94
+ * Skips sessions that are currently running a quality gate check.
95
+ */
96
+ function cleanupStaleSessions() {
97
+ const now = Date.now();
98
+ for (const [sessionId, state] of sessions) {
99
+ if (state.running)
100
+ continue; // Don't evict active sessions
101
+ if (now - state.lastAccess > SESSION_TTL_MS) {
102
+ if (state.idleDebounce) {
103
+ clearTimeout(state.idleDebounce);
104
+ }
105
+ sessions.delete(sessionId);
106
+ }
107
+ }
108
+ }
109
+ function startCleanupInterval() {
110
+ if (cleanupIntervalId)
111
+ return;
112
+ cleanupIntervalId = setInterval(cleanupStaleSessions, CLEANUP_INTERVAL_MS);
113
+ // Allow the process to exit even if the interval is running
114
+ if (cleanupIntervalId.unref) {
115
+ cleanupIntervalId.unref();
116
+ }
117
+ }
118
+ function getSessionState(sessionId) {
119
+ const existing = sessions.get(sessionId);
120
+ if (existing) {
121
+ existing.lastAccess = Date.now();
122
+ return existing;
123
+ }
124
+ startCleanupInterval();
125
+ const now = Date.now();
126
+ const state = {
127
+ lastRunAt: 0,
128
+ dirty: true,
129
+ qualityGatePassed: false,
130
+ idleDebounce: null,
131
+ running: false,
132
+ lastAccess: now,
133
+ };
134
+ sessions.set(sessionId, state);
135
+ return state;
136
+ }
137
+ function isSessionReadOnly(permission) {
138
+ if (!permission)
139
+ return false;
140
+ return permission.some((rule) => (rule.permission === 'edit' || rule.permission === 'bash') && rule.action === 'deny');
141
+ }
142
+ async function log(client, level, message, extra) {
143
+ try {
144
+ await client.app.log({
145
+ body: {
146
+ service: SERVICE_NAME,
147
+ level,
148
+ message,
149
+ extra,
150
+ },
151
+ });
152
+ }
153
+ catch {
154
+ return undefined;
155
+ }
156
+ }
157
+ export async function createQualityGateHooks(input) {
158
+ const { client, $, directory } = input;
159
+ async function ciShExists() {
160
+ try {
161
+ await $ `test -f ${directory}/management/ci.sh`.quiet();
162
+ return true;
163
+ }
164
+ catch {
165
+ return false;
166
+ }
167
+ }
168
+ async function _ensureSessionMetadata(sessionId, state) {
169
+ if (state.isReadOnly !== undefined)
170
+ return;
171
+ try {
172
+ const response = await client.session.get({ path: { id: sessionId } });
173
+ if (response.data) {
174
+ state.parentID = response.data.parentID;
175
+ const permission = response.data.permission;
176
+ state.isReadOnly = isSessionReadOnly(permission);
177
+ await log(client, 'debug', 'session.metadata-fetched', {
178
+ sessionId,
179
+ parentID: state.parentID,
180
+ isReadOnly: state.isReadOnly,
181
+ });
182
+ }
183
+ }
184
+ catch {
185
+ state.isReadOnly = false;
186
+ await log(client, 'warn', 'session.metadata-fetch-failed', { sessionId });
187
+ }
188
+ }
189
+ async function runQualityGate(sessionId) {
190
+ const state = getSessionState(sessionId);
191
+ if (!sessions.has(sessionId)) {
192
+ await log(client, 'debug', 'quality-gate.skipped (session deleted)', { sessionId });
193
+ return;
194
+ }
195
+ if (state.running) {
196
+ await log(client, 'debug', 'quality-gate.skipped (already running)', { sessionId });
197
+ return;
198
+ }
199
+ const now = Date.now();
200
+ const cacheExpired = now - state.lastRunAt > 30 * 1000;
201
+ if (state.qualityGatePassed && !state.dirty && !cacheExpired) {
202
+ await log(client, 'debug', 'quality-gate.skipped (cached pass)', { sessionId });
203
+ return;
204
+ }
205
+ const hasCiSh = await ciShExists();
206
+ if (!hasCiSh) {
207
+ await log(client, 'debug', 'quality-gate.skipped (no ci.sh)', { sessionId });
208
+ return;
209
+ }
210
+ state.running = true;
211
+ try {
212
+ await log(client, 'info', 'quality-gate.started', { sessionId, directory });
213
+ let ciOutput = '';
214
+ let ciPassed = false;
215
+ let timedOut = false;
216
+ const ciPath = `${directory}/management/ci.sh`;
217
+ // eslint-disable-next-line no-undef
218
+ const proc = Bun.spawn(['bash', ciPath], {
219
+ cwd: directory,
220
+ stdout: 'pipe',
221
+ stderr: 'pipe',
222
+ });
223
+ let timeoutId = null;
224
+ let forceKillTimeoutId = null;
225
+ const timeoutPromise = new Promise((resolve) => {
226
+ timeoutId = setTimeout(() => {
227
+ timedOut = true;
228
+ // Graceful termination: SIGTERM first, then SIGKILL after grace period
229
+ proc.kill('SIGTERM');
230
+ forceKillTimeoutId = setTimeout(() => {
231
+ // Force kill if still running after grace period
232
+ try {
233
+ proc.kill('SIGKILL');
234
+ }
235
+ catch {
236
+ // Process already terminated
237
+ }
238
+ }, 5000);
239
+ resolve();
240
+ }, CI_TIMEOUT_MS);
241
+ });
242
+ // Race the process completion against the timeout
243
+ await Promise.race([proc.exited, timeoutPromise]);
244
+ // Clear timeouts if process completed before timeout
245
+ if (timeoutId) {
246
+ clearTimeout(timeoutId);
247
+ }
248
+ if (forceKillTimeoutId) {
249
+ clearTimeout(forceKillTimeoutId);
250
+ }
251
+ const exitCode = proc.exitCode;
252
+ const stdout = await new Response(proc.stdout).text();
253
+ const stderr = await new Response(proc.stderr).text();
254
+ if (timedOut) {
255
+ await log(client, 'warn', 'quality-gate.timeout', {
256
+ sessionId,
257
+ timeoutMs: CI_TIMEOUT_MS,
258
+ });
259
+ ciOutput =
260
+ `CI execution timed out after ${CI_TIMEOUT_MS / 1000} seconds\n\n${stdout}\n${stderr}`.trim();
261
+ ciPassed = false;
262
+ }
263
+ else {
264
+ ciOutput = stdout + (stderr ? `\n${stderr}` : '');
265
+ ciPassed = exitCode === 0;
266
+ }
267
+ state.lastRunAt = Date.now();
268
+ state.dirty = false;
269
+ state.qualityGatePassed = ciPassed;
270
+ if (!ciPassed) {
271
+ // Sanitize secrets first, then truncate to last 20 lines to reduce prompt size
272
+ const sanitizedOutput = truncateOutput(sanitizeOutput(ciOutput), 20);
273
+ const instructions = `
274
+
275
+ **Important:** Do not interrupt your current task. Add "Fix quality gate failures" to your todo list and continue with what you were doing. Address the quality gate issues after completing your current task.
276
+
277
+ If the failure details are missing or truncated, run "management/ci.sh" to get the full output.`;
278
+ const message = timedOut
279
+ ? `⏱️ Quality gate timed out\n\nThe CI execution exceeded the ${CI_TIMEOUT_MS / 1000} second timeout. The build may be hanging or taking too long.\n\n\`\`\`\n${sanitizedOutput}\n\`\`\`${instructions}`
280
+ : `❌ Quality gate failed\n\nThe CI checks did not pass. Please review the output below and fix the issues:\n\n\`\`\`\n${sanitizedOutput}\n\`\`\`${instructions}`;
281
+ await client.session.prompt({
282
+ path: { id: sessionId },
283
+ body: {
284
+ parts: [
285
+ {
286
+ type: 'text',
287
+ text: message,
288
+ },
289
+ ],
290
+ },
291
+ });
292
+ await log(client, 'debug', timedOut ? 'quality-gate.timeout-reported' : 'quality-gate.failed', { sessionId });
293
+ }
294
+ }
295
+ finally {
296
+ state.running = false;
297
+ }
298
+ }
299
+ return {
300
+ event: async ({ event }) => {
301
+ const eventProps = event.properties;
302
+ const sessionId = eventProps?.sessionID;
303
+ if (event.type === 'session.created') {
304
+ const sessionInfo = event.properties?.info;
305
+ const id = sessionInfo?.id;
306
+ if (id) {
307
+ startCleanupInterval();
308
+ const isReadOnly = isSessionReadOnly(sessionInfo?.permission);
309
+ sessions.set(id, {
310
+ lastRunAt: 0,
311
+ dirty: true,
312
+ qualityGatePassed: false,
313
+ idleDebounce: null,
314
+ running: false,
315
+ parentID: sessionInfo?.parentID,
316
+ isReadOnly,
317
+ lastAccess: Date.now(),
318
+ });
319
+ await log(client, 'debug', 'session.created', {
320
+ sessionId: id,
321
+ parentID: sessionInfo?.parentID,
322
+ isReadOnly,
323
+ });
324
+ }
325
+ }
326
+ if (event.type === 'session.deleted' && sessionId) {
327
+ const state = sessions.get(sessionId);
328
+ if (state?.idleDebounce) {
329
+ clearTimeout(state.idleDebounce);
330
+ }
331
+ sessions.delete(sessionId);
332
+ await log(client, 'debug', 'session.deleted', { sessionId });
333
+ }
334
+ if (event.type === 'session.idle' && sessionId) {
335
+ const state = getSessionState(sessionId);
336
+ if (state.parentID) {
337
+ await log(client, 'debug', 'quality-gate.skipped (sub-agent session)', {
338
+ sessionId,
339
+ parentID: state.parentID,
340
+ });
341
+ return;
342
+ }
343
+ if (state.isReadOnly) {
344
+ await log(client, 'debug', 'quality-gate.skipped (read-only session)', { sessionId });
345
+ return;
346
+ }
347
+ if (state.idleDebounce) {
348
+ clearTimeout(state.idleDebounce);
349
+ }
350
+ state.idleDebounce = setTimeout(async () => {
351
+ state.idleDebounce = null;
352
+ if (sessions.has(sessionId)) {
353
+ try {
354
+ await runQualityGate(sessionId);
355
+ }
356
+ catch (err) {
357
+ await log(client, 'error', 'quality-gate.unhandled-error', {
358
+ sessionId,
359
+ error: String(err),
360
+ });
361
+ }
362
+ }
363
+ }, IDLE_DEBOUNCE_MS);
364
+ }
365
+ },
366
+ 'tool.execute.before': async (input) => {
367
+ const sessionId = input.sessionID;
368
+ if (!sessionId)
369
+ return;
370
+ const state = getSessionState(sessionId);
371
+ if (input.tool === 'edit' || input.tool === 'write' || input.tool === 'patch') {
372
+ state.dirty = true;
373
+ state.qualityGatePassed = false;
374
+ await log(client, 'debug', 'session.dirty', { sessionId, tool: input.tool });
375
+ }
376
+ },
377
+ };
378
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Unit tests for stop-quality-gate module
3
+ *
4
+ * Tests focus on pure functions that can be tested in isolation:
5
+ * - sanitizeOutput: redacts secrets from CI output
6
+ * - isSessionReadOnly: determines if session has write permissions
7
+ */
8
+ export {};