@syntesseraai/opencode-feature-factory 0.1.19 → 0.1.20

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,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@syntesseraai/opencode-feature-factory",
4
- "version": "0.1.19",
4
+ "version": "0.1.20",
5
5
  "description": "OpenCode plugin for Feature Factory agents - provides planning, implementation, review, testing, and validation agents",
6
6
  "type": "module",
7
7
  "license": "MIT",
package/src/index.ts CHANGED
@@ -1,10 +1,38 @@
1
- import type { Plugin, Hooks } from '@opencode-ai/plugin';
1
+ import type { Plugin, Hooks, PluginInput } from '@opencode-ai/plugin';
2
2
  import * as path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { mkdir, access, writeFile, readFile } from 'node:fs/promises';
5
5
  import { constants as fsConstants } from 'node:fs';
6
6
  import { createQualityGateHooks } from './stop-quality-gate';
7
7
 
8
+ const SERVICE_NAME = 'feature-factory';
9
+
10
+ type Client = PluginInput['client'];
11
+
12
+ /**
13
+ * Log a message using the OpenCode client's structured logging.
14
+ * Silently fails if logging is unavailable.
15
+ */
16
+ async function log(
17
+ client: Client,
18
+ level: 'debug' | 'info' | 'warn' | 'error',
19
+ message: string,
20
+ extra?: Record<string, unknown>
21
+ ): Promise<void> {
22
+ try {
23
+ await client.app.log({
24
+ body: {
25
+ service: SERVICE_NAME,
26
+ level,
27
+ message,
28
+ extra,
29
+ },
30
+ });
31
+ } catch {
32
+ // Logging failure should not affect plugin operation
33
+ }
34
+ }
35
+
8
36
  /**
9
37
  * List of agent templates to sync to projects.
10
38
  * Each entry maps a destination filename to its template path relative to this module.
@@ -147,7 +175,7 @@ function mergeHooks(...hookSets: Partial<Hooks>[]): Hooks {
147
175
  * - If management/ci.sh does not exist, quality gate does not run
148
176
  */
149
177
  export const FeatureFactoryPlugin: Plugin = async (input) => {
150
- const { worktree, directory } = input;
178
+ const { worktree, directory, client } = input;
151
179
  const rootDir = resolveRootDir({ worktree, directory });
152
180
 
153
181
  // Skip if no valid directory (e.g., global config with no project)
@@ -158,16 +186,21 @@ export const FeatureFactoryPlugin: Plugin = async (input) => {
158
186
  // Run initial sync on plugin load (silent - errors are swallowed)
159
187
  try {
160
188
  await ensureAgentsInstalled(rootDir);
161
- } catch {
162
- // Silent autosync shouldn't crash OpenCode startup
189
+ } catch (error) {
190
+ await log(client, 'warn', 'agent-sync.init-error', {
191
+ rootDir,
192
+ error: String(error),
193
+ });
163
194
  }
164
195
 
165
196
  // Create quality gate hooks
166
197
  let qualityGateHooks: Partial<Hooks> = {};
167
198
  try {
168
199
  qualityGateHooks = await createQualityGateHooks(input);
169
- } catch {
170
- // Silent failure - quality gate shouldn't crash OpenCode startup
200
+ } catch (error) {
201
+ await log(client, 'error', 'quality-gate.init-error', {
202
+ error: String(error),
203
+ });
171
204
  }
172
205
 
173
206
  // Agent sync hooks
@@ -177,8 +210,11 @@ export const FeatureFactoryPlugin: Plugin = async (input) => {
177
210
  if (event.type === 'installation.updated') {
178
211
  try {
179
212
  await ensureAgentsInstalled(rootDir);
180
- } catch {
181
- // Silent failure
213
+ } catch (error) {
214
+ await log(client, 'warn', 'agent-sync.update-error', {
215
+ rootDir,
216
+ error: String(error),
217
+ });
182
218
  }
183
219
  }
184
220
  },
@@ -6,56 +6,7 @@
6
6
  * - isSessionReadOnly: determines if session has write permissions
7
7
  */
8
8
 
9
- // Re-implement the functions here for testing since they're not exported
10
- // This allows us to test the logic without modifying the source file exports
11
-
12
- const SECRET_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
13
- // AWS Access Key IDs
14
- { pattern: /AKIA[0-9A-Z]{16}/g, replacement: '[REDACTED_AWS_KEY]' },
15
- // GitHub Personal Access Tokens (classic)
16
- { pattern: /ghp_[A-Za-z0-9]{36}/g, replacement: '[REDACTED_GH_TOKEN]' },
17
- // GitHub Personal Access Tokens (fine-grained)
18
- { pattern: /github_pat_[A-Za-z0-9_]{22,}/g, replacement: '[REDACTED_GH_TOKEN]' },
19
- // GitHub OAuth tokens
20
- { pattern: /gho_[A-Za-z0-9]{36}/g, replacement: '[REDACTED_GH_TOKEN]' },
21
- // GitHub App tokens (user-to-server and server-to-server)
22
- { pattern: /ghu_[A-Za-z0-9]{36}/g, replacement: '[REDACTED_GH_TOKEN]' },
23
- { pattern: /ghs_[A-Za-z0-9]{36}/g, replacement: '[REDACTED_GH_TOKEN]' },
24
- // GitLab Personal Access Tokens
25
- { pattern: /glpat-[A-Za-z0-9-]{20,}/g, replacement: '[REDACTED_GITLAB_TOKEN]' },
26
- // npm tokens
27
- { pattern: /npm_[A-Za-z0-9]{36}/g, replacement: '[REDACTED_NPM_TOKEN]' },
28
- // Bearer tokens
29
- { pattern: /Bearer\s+[\w\-.]+/gi, replacement: 'Bearer [REDACTED]' },
30
- // API keys (api_key, api-key, apikey, apikeys, etc.)
31
- { pattern: /api[_-]?keys?[=:\s]+['"]?[\w-]+['"]?/gi, replacement: 'api_key=[REDACTED]' },
32
- // Tokens (token, tokens)
33
- { pattern: /tokens?[=:\s]+['"]?[\w-]+['"]?/gi, replacement: 'token=[REDACTED]' },
34
- // Passwords
35
- { pattern: /passwords?[=:\s]+['"]?[^\s'"]+['"]?/gi, replacement: 'password=[REDACTED]' },
36
- // Generic secrets
37
- { pattern: /secrets?[=:\s]+['"]?[^\s'"]+['"]?/gi, replacement: 'secret=[REDACTED]' },
38
- // Base64-encoded long strings that look like secrets (40+ chars of base64 alphabet)
39
- { pattern: /[A-Za-z0-9+/]{40,}={0,2}/g, replacement: '[REDACTED_BASE64]' },
40
- // Private keys (RSA, DSA, EC, OpenSSH, etc.)
41
- {
42
- pattern: /-----BEGIN[\s\w]+PRIVATE KEY-----[\s\S]*?-----END[\s\w]+PRIVATE KEY-----/g,
43
- replacement: '[REDACTED_PRIVATE_KEY]',
44
- },
45
- // Database connection strings with credentials (postgres, postgresql, mysql, mongodb, redis)
46
- {
47
- pattern: /(postgres|postgresql|mysql|mongodb(\+srv)?|rediss?):\/\/[^\s]+:[^@\s]+@[^\s]+/gi,
48
- replacement: '[REDACTED_CONNECTION_STRING]',
49
- },
50
- ];
51
-
52
- function sanitizeOutput(output: string): string {
53
- let sanitized = output;
54
- for (const { pattern, replacement } of SECRET_PATTERNS) {
55
- sanitized = sanitized.replace(pattern, replacement);
56
- }
57
- return sanitized;
58
- }
9
+ import { SECRET_PATTERNS, sanitizeOutput } from './stop-quality-gate';
59
10
 
60
11
  interface PermissionRule {
61
12
  permission: string;
@@ -204,14 +155,44 @@ describe('sanitizeOutput', () => {
204
155
  });
205
156
 
206
157
  describe('tokens', () => {
207
- it('should redact token assignments', () => {
158
+ it('should redact token assignments with 8+ char values', () => {
208
159
  const input = 'token=ghp_xxxxxxxxxxxxxxxxxxxx';
209
160
  const result = sanitizeOutput(input);
210
161
  expect(result).toBe('token=[REDACTED]');
211
162
  });
212
163
 
213
- it('should redact tokens plural', () => {
214
- const input = 'tokens: secret123';
164
+ it('should redact tokens plural with 8+ char values', () => {
165
+ const input = 'tokens: secret12345';
166
+ const result = sanitizeOutput(input);
167
+ expect(result).toBe('token=[REDACTED]');
168
+ });
169
+
170
+ it('should NOT redact token with short values (less than 8 chars)', () => {
171
+ const input = 'token=abc123';
172
+ const result = sanitizeOutput(input);
173
+ expect(result).toBe('token=abc123');
174
+ });
175
+
176
+ it('should NOT redact phrases like "token count: 5" (value too short)', () => {
177
+ const input = 'token count: 5';
178
+ const result = sanitizeOutput(input);
179
+ expect(result).toBe('token count: 5');
180
+ });
181
+
182
+ it('should NOT redact "token: abc" (value too short)', () => {
183
+ const input = 'token: abc';
184
+ const result = sanitizeOutput(input);
185
+ expect(result).toBe('token: abc');
186
+ });
187
+
188
+ it('should redact actual token values that are 8+ chars', () => {
189
+ const input = 'token=abcd1234efgh';
190
+ const result = sanitizeOutput(input);
191
+ expect(result).toBe('token=[REDACTED]');
192
+ });
193
+
194
+ it('should redact quoted tokens with 8+ char values', () => {
195
+ const input = 'token="my-secret-token-value"';
215
196
  const result = sanitizeOutput(input);
216
197
  expect(result).toBe('token=[REDACTED]');
217
198
  });
@@ -270,6 +251,33 @@ describe('sanitizeOutput', () => {
270
251
  const result = sanitizeOutput(base64);
271
252
  expect(result).toBe('[REDACTED_BASE64]');
272
253
  });
254
+
255
+ it('should redact base64 strings up to 500 chars (ReDoS prevention)', () => {
256
+ // Generate a 500-char base64 string
257
+ const base64 = 'A'.repeat(500);
258
+ const result = sanitizeOutput(base64);
259
+ expect(result).toBe('[REDACTED_BASE64]');
260
+ });
261
+
262
+ it('should handle base64 strings over 500 chars by matching only first 500 (ReDoS prevention)', () => {
263
+ // Generate a 501-char base64 string - only first 500 chars match
264
+ const base64 = 'A'.repeat(501);
265
+ const result = sanitizeOutput(base64);
266
+ // Pattern matches the first 500 chars, leaving 1 char behind
267
+ expect(result).toBe('[REDACTED_BASE64]A');
268
+ });
269
+
270
+ it('should handle base64 at exactly 40 chars (minimum threshold)', () => {
271
+ const base64 = 'A'.repeat(40);
272
+ const result = sanitizeOutput(base64);
273
+ expect(result).toBe('[REDACTED_BASE64]');
274
+ });
275
+
276
+ it('should NOT redact base64 strings under 40 chars', () => {
277
+ const base64 = 'A'.repeat(39);
278
+ const result = sanitizeOutput(base64);
279
+ expect(result).toBe(base64);
280
+ });
273
281
  });
274
282
 
275
283
  describe('multiple secrets', () => {
@@ -335,6 +343,130 @@ Done loading.`);
335
343
  });
336
344
  });
337
345
 
346
+ describe('GCP credentials', () => {
347
+ it('should redact GCP API keys', () => {
348
+ const input = 'Using GCP key: AIzaSyDaGmWKa4JsXZ-HjGw7ISLn_3namBGewQe';
349
+ const result = sanitizeOutput(input);
350
+ expect(result).toBe('Using GCP key: [REDACTED_GCP_KEY]');
351
+ });
352
+
353
+ it('should redact multiple GCP API keys', () => {
354
+ const input =
355
+ 'Key1: AIzaSyDaGmWKa4JsXZ-HjGw7ISLn_3namBGewQe Key2: AIzaSyB-1234567890abcdefghijklmnopqrstu';
356
+ const result = sanitizeOutput(input);
357
+ expect(result).toBe('Key1: [REDACTED_GCP_KEY] Key2: [REDACTED_GCP_KEY]');
358
+ });
359
+
360
+ it('should not redact partial GCP API key patterns', () => {
361
+ const input = 'AIza123'; // Too short
362
+ const result = sanitizeOutput(input);
363
+ expect(result).toBe('AIza123');
364
+ });
365
+
366
+ it('should redact GCP OAuth tokens', () => {
367
+ const input = 'Authorization: ya29.a0AfH6SMBx-example-token-value_123';
368
+ const result = sanitizeOutput(input);
369
+ expect(result).toBe('Authorization: [REDACTED_GCP_TOKEN]');
370
+ });
371
+
372
+ it('should redact GCP OAuth tokens with various characters', () => {
373
+ const input = 'token=ya29.Gl-abc_XYZ-123';
374
+ const result = sanitizeOutput(input);
375
+ expect(result).toBe('token=[REDACTED_GCP_TOKEN]');
376
+ });
377
+ });
378
+
379
+ describe('Slack tokens', () => {
380
+ it('should redact Slack bot tokens', () => {
381
+ const input = 'SLACK_BOT_TOKEN=xoxb-123456789012-1234567890123-AbCdEfGhIjKlMnOpQrStUvWx';
382
+ const result = sanitizeOutput(input);
383
+ expect(result).toBe('SLACK_BOT_TOKEN=[REDACTED_SLACK_TOKEN]');
384
+ });
385
+
386
+ it('should redact Slack user tokens', () => {
387
+ const input =
388
+ 'Using xoxp-123456789012-123456789012-123456789012-abcdef1234567890abcdef1234567890';
389
+ const result = sanitizeOutput(input);
390
+ expect(result).toBe('Using [REDACTED_SLACK_TOKEN]');
391
+ });
392
+
393
+ it('should redact Slack app tokens', () => {
394
+ const input =
395
+ 'APP_TOKEN=xapp-1-A0123BCDEFG-1234567890123-abcdefghijklmnopqrstuvwxyz0123456789';
396
+ const result = sanitizeOutput(input);
397
+ expect(result).toBe('APP_TOKEN=[REDACTED_SLACK_TOKEN]');
398
+ });
399
+
400
+ it('should redact Slack webhook URLs', () => {
401
+ const input =
402
+ 'Webhook: https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX';
403
+ const result = sanitizeOutput(input);
404
+ expect(result).toBe('Webhook: https://[REDACTED_SLACK_WEBHOOK]');
405
+ });
406
+
407
+ it('should redact multiple different Slack token types', () => {
408
+ const input = `
409
+ Bot: xoxb-123-456-abc
410
+ User: xoxp-789-012-def
411
+ App: xapp-345-ghi
412
+ `;
413
+ const result = sanitizeOutput(input);
414
+ expect(result).toContain('[REDACTED_SLACK_TOKEN]');
415
+ expect(result).not.toContain('xoxb-');
416
+ expect(result).not.toContain('xoxp-');
417
+ expect(result).not.toContain('xapp-');
418
+ });
419
+ });
420
+
421
+ describe('Stripe keys', () => {
422
+ it('should redact Stripe live secret keys', () => {
423
+ // 24 chars after sk_live_: 51ABC123DEF456GHI789JKLM
424
+ const input = 'STRIPE_SECRET_KEY=sk_live_51ABC123DEF456GHI789JKLM';
425
+ const result = sanitizeOutput(input);
426
+ expect(result).toBe('STRIPE_SECRET_KEY=[REDACTED_STRIPE_KEY]');
427
+ });
428
+
429
+ it('should redact Stripe test secret keys', () => {
430
+ // 24 chars after sk_test_: 51ABC123DEF456GHI789JKLM
431
+ const input = 'Using sk_test_51ABC123DEF456GHI789JKLM for testing';
432
+ const result = sanitizeOutput(input);
433
+ expect(result).toBe('Using [REDACTED_STRIPE_KEY] for testing');
434
+ });
435
+
436
+ it('should redact Stripe live restricted keys', () => {
437
+ // 24 chars after rk_live_: 51ABC123DEF456GHI789JKLM
438
+ const input = 'RESTRICTED_KEY=rk_live_51ABC123DEF456GHI789JKLM';
439
+ const result = sanitizeOutput(input);
440
+ expect(result).toBe('RESTRICTED_KEY=[REDACTED_STRIPE_KEY]');
441
+ });
442
+
443
+ it('should redact Stripe test restricted keys', () => {
444
+ // 24 chars after rk_test_: 51ABC123DEF456GHI789JKLM
445
+ const input = 'Using rk_test_51ABC123DEF456GHI789JKLM for testing';
446
+ const result = sanitizeOutput(input);
447
+ expect(result).toBe('Using [REDACTED_STRIPE_KEY] for testing');
448
+ });
449
+
450
+ it('should not redact Stripe keys that are too short', () => {
451
+ const input = 'sk_live_short'; // Less than 24 characters after prefix
452
+ const result = sanitizeOutput(input);
453
+ expect(result).toBe('sk_live_short');
454
+ });
455
+
456
+ it('should redact multiple Stripe keys', () => {
457
+ // 24 chars after each prefix
458
+ const input = 'Live: sk_live_51ABC123DEF456GHI789JKLM Test: sk_test_51XYZ789ABC123DEF456GHIJ';
459
+ const result = sanitizeOutput(input);
460
+ expect(result).toBe('Live: [REDACTED_STRIPE_KEY] Test: [REDACTED_STRIPE_KEY]');
461
+ });
462
+
463
+ it('should redact long Stripe keys', () => {
464
+ const input = 'sk_live_51ABC123DEF456GHI789JKLmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOP';
465
+ const result = sanitizeOutput(input);
466
+ expect(result).toBe('[REDACTED_STRIPE_KEY]');
467
+ });
468
+ });
469
+
338
470
  describe('database connection strings', () => {
339
471
  it('should redact PostgreSQL connection strings', () => {
340
472
  const input = 'DATABASE_URL=postgres://admin:secretpass123@db.example.com:5432/mydb';
@@ -365,6 +497,30 @@ Done loading.`);
365
497
  const result = sanitizeOutput(input);
366
498
  expect(result).toBe('[REDACTED_CONNECTION_STRING]');
367
499
  });
500
+
501
+ it('should redact connection strings with URL-encoded passwords', () => {
502
+ const input = 'mongodb://user:p%40ss%23word@host/db';
503
+ const result = sanitizeOutput(input);
504
+ expect(result).toBe('[REDACTED_CONNECTION_STRING]');
505
+ });
506
+
507
+ it('should redact PostgreSQL with URL-encoded @ in password', () => {
508
+ const input = 'postgres://admin:secret%40pass@db.example.com:5432/mydb';
509
+ const result = sanitizeOutput(input);
510
+ expect(result).toBe('[REDACTED_CONNECTION_STRING]');
511
+ });
512
+
513
+ it('should redact connection strings with multiple URL-encoded characters', () => {
514
+ const input = 'mysql://root:p%40ss%3Dw%26rd%21@localhost:3306/testdb';
515
+ const result = sanitizeOutput(input);
516
+ expect(result).toBe('[REDACTED_CONNECTION_STRING]');
517
+ });
518
+
519
+ it('should redact MongoDB+srv with URL-encoded password', () => {
520
+ const input = 'mongodb+srv://user:my%40complex%23pass@cluster.mongodb.net/database';
521
+ const result = sanitizeOutput(input);
522
+ expect(result).toBe('[REDACTED_CONNECTION_STRING]');
523
+ });
368
524
  });
369
525
 
370
526
  describe('non-secret content', () => {
@@ -6,7 +6,7 @@ const CI_TIMEOUT_MS = 300000; // 5 minutes
6
6
  const SESSION_TTL_MS = 3600000; // 1 hour
7
7
  const CLEANUP_INTERVAL_MS = 600000; // 10 minutes
8
8
 
9
- const SECRET_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
9
+ export const SECRET_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
10
10
  // AWS Access Key IDs
11
11
  { pattern: /AKIA[0-9A-Z]{16}/g, replacement: '[REDACTED_AWS_KEY]' },
12
12
  // GitHub Personal Access Tokens (classic)
@@ -22,35 +22,57 @@ const SECRET_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
22
22
  { pattern: /glpat-[A-Za-z0-9-]{20,}/g, replacement: '[REDACTED_GITLAB_TOKEN]' },
23
23
  // npm tokens
24
24
  { pattern: /npm_[A-Za-z0-9]{36}/g, replacement: '[REDACTED_NPM_TOKEN]' },
25
+ // Slack bot tokens
26
+ { pattern: /xoxb-[0-9A-Za-z-]+/g, replacement: '[REDACTED_SLACK_TOKEN]' },
27
+ // Slack user tokens
28
+ { pattern: /xoxp-[0-9A-Za-z-]+/g, replacement: '[REDACTED_SLACK_TOKEN]' },
29
+ // Slack app tokens
30
+ { pattern: /xapp-[0-9A-Za-z-]+/g, replacement: '[REDACTED_SLACK_TOKEN]' },
31
+ // Slack webhook URLs
32
+ { pattern: /hooks\.slack\.com\/services\/[A-Z0-9/]+/g, replacement: '[REDACTED_SLACK_WEBHOOK]' },
33
+ // Stripe live secret keys
34
+ { pattern: /sk_live_[0-9a-zA-Z]{24,}/g, replacement: '[REDACTED_STRIPE_KEY]' },
35
+ // Stripe test secret keys
36
+ { pattern: /sk_test_[0-9a-zA-Z]{24,}/g, replacement: '[REDACTED_STRIPE_KEY]' },
37
+ // Stripe live restricted keys
38
+ { pattern: /rk_live_[0-9a-zA-Z]{24,}/g, replacement: '[REDACTED_STRIPE_KEY]' },
39
+ // Stripe test restricted keys
40
+ { pattern: /rk_test_[0-9a-zA-Z]{24,}/g, replacement: '[REDACTED_STRIPE_KEY]' },
25
41
  // Bearer tokens
26
42
  { pattern: /Bearer\s+[\w\-.]+/gi, replacement: 'Bearer [REDACTED]' },
27
43
  // API keys (api_key, api-key, apikey, apikeys, etc.)
28
44
  { pattern: /api[_-]?keys?[=:\s]+['"]?[\w-]+['"]?/gi, replacement: 'api_key=[REDACTED]' },
29
- // Tokens (token, tokens)
30
- { pattern: /tokens?[=:\s]+['"]?[\w-]+['"]?/gi, replacement: 'token=[REDACTED]' },
45
+ // Tokens (token, tokens) - require minimum 8 char value to reduce false positives
46
+ { pattern: /tokens?[=:\s]+['"]?([A-Za-z0-9_-]{8,})['"]?/gi, replacement: 'token=[REDACTED]' },
31
47
  // Passwords
32
48
  { pattern: /passwords?[=:\s]+['"]?[^\s'"]+['"]?/gi, replacement: 'password=[REDACTED]' },
33
49
  // Generic secrets
34
50
  { pattern: /secrets?[=:\s]+['"]?[^\s'"]+['"]?/gi, replacement: 'secret=[REDACTED]' },
35
- // Base64-encoded long strings that look like secrets (40+ chars of base64 alphabet)
36
- { pattern: /[A-Za-z0-9+/]{40,}={0,2}/g, replacement: '[REDACTED_BASE64]' },
51
+ // Base64-encoded long strings that look like secrets (40-500 chars to prevent ReDoS)
52
+ { pattern: /[A-Za-z0-9+/]{40,500}={0,2}/g, replacement: '[REDACTED_BASE64]' },
37
53
  // Private keys (RSA, DSA, EC, OpenSSH, etc.)
38
54
  {
39
55
  pattern: /-----BEGIN[\s\w]+PRIVATE KEY-----[\s\S]*?-----END[\s\w]+PRIVATE KEY-----/g,
40
56
  replacement: '[REDACTED_PRIVATE_KEY]',
41
57
  },
42
58
  // Database connection strings with credentials (postgres, postgresql, mysql, mongodb, redis)
59
+ // Password portion handles URL-encoded characters like %40 (for @) and %23 (for #)
43
60
  {
44
- pattern: /(postgres|postgresql|mysql|mongodb(\+srv)?|rediss?):\/\/[^\s]+:[^@\s]+@[^\s]+/gi,
61
+ pattern:
62
+ /(postgres|postgresql|mysql|mongodb(\+srv)?|rediss?):\/\/[^\s/:]+:(?:[^@\s]|%[0-9A-Fa-f]{2})+@[^\s]+/gi,
45
63
  replacement: '[REDACTED_CONNECTION_STRING]',
46
64
  },
65
+ // GCP API keys
66
+ { pattern: /AIza[0-9A-Za-z_-]{35}/g, replacement: '[REDACTED_GCP_KEY]' },
67
+ // GCP OAuth tokens
68
+ { pattern: /ya29\.[0-9A-Za-z_-]+/g, replacement: '[REDACTED_GCP_TOKEN]' },
47
69
  ];
48
70
 
49
71
  /**
50
72
  * Sanitizes CI output by redacting common secret patterns before sending to the LLM.
51
73
  * This helps prevent accidental exposure of sensitive information in prompts.
52
74
  */
53
- function sanitizeOutput(output: string): string {
75
+ export function sanitizeOutput(output: string): string {
54
76
  let sanitized = output;
55
77
  for (const { pattern, replacement } of SECRET_PATTERNS) {
56
78
  sanitized = sanitized.replace(pattern, replacement);
@@ -234,10 +256,20 @@ export async function createQualityGateHooks(input: PluginInput): Promise<Partia
234
256
  });
235
257
 
236
258
  let timeoutId: ReturnType<typeof setTimeout> | null = null;
259
+ let forceKillTimeoutId: ReturnType<typeof setTimeout> | null = null;
237
260
  const timeoutPromise = new Promise<void>((resolve) => {
238
261
  timeoutId = setTimeout(() => {
239
262
  timedOut = true;
240
- proc.kill();
263
+ // Graceful termination: SIGTERM first, then SIGKILL after grace period
264
+ proc.kill('SIGTERM');
265
+ forceKillTimeoutId = setTimeout(() => {
266
+ // Force kill if still running after grace period
267
+ try {
268
+ proc.kill('SIGKILL');
269
+ } catch {
270
+ // Process already terminated
271
+ }
272
+ }, 5000);
241
273
  resolve();
242
274
  }, CI_TIMEOUT_MS);
243
275
  });
@@ -245,10 +277,13 @@ export async function createQualityGateHooks(input: PluginInput): Promise<Partia
245
277
  // Race the process completion against the timeout
246
278
  await Promise.race([proc.exited, timeoutPromise]);
247
279
 
248
- // Clear timeout if process completed before timeout
280
+ // Clear timeouts if process completed before timeout
249
281
  if (timeoutId) {
250
282
  clearTimeout(timeoutId);
251
283
  }
284
+ if (forceKillTimeoutId) {
285
+ clearTimeout(forceKillTimeoutId);
286
+ }
252
287
 
253
288
  const exitCode = proc.exitCode;
254
289
  const stdout = await new Response(proc.stdout).text();