opencastle 0.26.1 → 0.27.1

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.
Files changed (226) hide show
  1. package/README.md +7 -1
  2. package/bin/cli.mjs +10 -0
  3. package/dist/cli/agents.d.ts +3 -0
  4. package/dist/cli/agents.d.ts.map +1 -0
  5. package/dist/cli/agents.js +161 -0
  6. package/dist/cli/agents.js.map +1 -0
  7. package/dist/cli/baselines.d.ts +3 -0
  8. package/dist/cli/baselines.d.ts.map +1 -0
  9. package/dist/cli/baselines.js +128 -0
  10. package/dist/cli/baselines.js.map +1 -0
  11. package/dist/cli/convoy/engine.d.ts +68 -2
  12. package/dist/cli/convoy/engine.d.ts.map +1 -1
  13. package/dist/cli/convoy/engine.js +2102 -26
  14. package/dist/cli/convoy/engine.js.map +1 -1
  15. package/dist/cli/convoy/engine.test.js +1572 -70
  16. package/dist/cli/convoy/engine.test.js.map +1 -1
  17. package/dist/cli/convoy/events.d.ts +4 -1
  18. package/dist/cli/convoy/events.d.ts.map +1 -1
  19. package/dist/cli/convoy/events.js +74 -13
  20. package/dist/cli/convoy/events.js.map +1 -1
  21. package/dist/cli/convoy/events.test.js +154 -27
  22. package/dist/cli/convoy/events.test.js.map +1 -1
  23. package/dist/cli/convoy/expertise.d.ts +16 -0
  24. package/dist/cli/convoy/expertise.d.ts.map +1 -0
  25. package/dist/cli/convoy/expertise.js +121 -0
  26. package/dist/cli/convoy/expertise.js.map +1 -0
  27. package/dist/cli/convoy/expertise.test.d.ts +2 -0
  28. package/dist/cli/convoy/expertise.test.d.ts.map +1 -0
  29. package/dist/cli/convoy/expertise.test.js +96 -0
  30. package/dist/cli/convoy/expertise.test.js.map +1 -0
  31. package/dist/cli/convoy/export.test.js +1 -0
  32. package/dist/cli/convoy/export.test.js.map +1 -1
  33. package/dist/cli/convoy/formula.d.ts +19 -0
  34. package/dist/cli/convoy/formula.d.ts.map +1 -0
  35. package/dist/cli/convoy/formula.js +142 -0
  36. package/dist/cli/convoy/formula.js.map +1 -0
  37. package/dist/cli/convoy/formula.test.d.ts +2 -0
  38. package/dist/cli/convoy/formula.test.d.ts.map +1 -0
  39. package/dist/cli/convoy/formula.test.js +342 -0
  40. package/dist/cli/convoy/formula.test.js.map +1 -0
  41. package/dist/cli/convoy/gates.d.ts +128 -0
  42. package/dist/cli/convoy/gates.d.ts.map +1 -0
  43. package/dist/cli/convoy/gates.js +606 -0
  44. package/dist/cli/convoy/gates.js.map +1 -0
  45. package/dist/cli/convoy/gates.test.d.ts +2 -0
  46. package/dist/cli/convoy/gates.test.d.ts.map +1 -0
  47. package/dist/cli/convoy/gates.test.js +976 -0
  48. package/dist/cli/convoy/gates.test.js.map +1 -0
  49. package/dist/cli/convoy/health.d.ts +11 -0
  50. package/dist/cli/convoy/health.d.ts.map +1 -1
  51. package/dist/cli/convoy/health.js +54 -0
  52. package/dist/cli/convoy/health.js.map +1 -1
  53. package/dist/cli/convoy/health.test.js +56 -1
  54. package/dist/cli/convoy/health.test.js.map +1 -1
  55. package/dist/cli/convoy/issues.d.ts +8 -0
  56. package/dist/cli/convoy/issues.d.ts.map +1 -0
  57. package/dist/cli/convoy/issues.js +98 -0
  58. package/dist/cli/convoy/issues.js.map +1 -0
  59. package/dist/cli/convoy/issues.test.d.ts +2 -0
  60. package/dist/cli/convoy/issues.test.d.ts.map +1 -0
  61. package/dist/cli/convoy/issues.test.js +107 -0
  62. package/dist/cli/convoy/issues.test.js.map +1 -0
  63. package/dist/cli/convoy/knowledge.d.ts +5 -0
  64. package/dist/cli/convoy/knowledge.d.ts.map +1 -0
  65. package/dist/cli/convoy/knowledge.js +116 -0
  66. package/dist/cli/convoy/knowledge.js.map +1 -0
  67. package/dist/cli/convoy/knowledge.test.d.ts +2 -0
  68. package/dist/cli/convoy/knowledge.test.d.ts.map +1 -0
  69. package/dist/cli/convoy/knowledge.test.js +87 -0
  70. package/dist/cli/convoy/knowledge.test.js.map +1 -0
  71. package/dist/cli/convoy/lessons.d.ts +17 -0
  72. package/dist/cli/convoy/lessons.d.ts.map +1 -0
  73. package/dist/cli/convoy/lessons.js +149 -0
  74. package/dist/cli/convoy/lessons.js.map +1 -0
  75. package/dist/cli/convoy/lessons.test.d.ts +2 -0
  76. package/dist/cli/convoy/lessons.test.d.ts.map +1 -0
  77. package/dist/cli/convoy/lessons.test.js +135 -0
  78. package/dist/cli/convoy/lessons.test.js.map +1 -0
  79. package/dist/cli/convoy/lock.d.ts +13 -0
  80. package/dist/cli/convoy/lock.d.ts.map +1 -0
  81. package/dist/cli/convoy/lock.js +88 -0
  82. package/dist/cli/convoy/lock.js.map +1 -0
  83. package/dist/cli/convoy/lock.test.d.ts +2 -0
  84. package/dist/cli/convoy/lock.test.d.ts.map +1 -0
  85. package/dist/cli/convoy/lock.test.js +136 -0
  86. package/dist/cli/convoy/lock.test.js.map +1 -0
  87. package/dist/cli/convoy/merge.d.ts +4 -0
  88. package/dist/cli/convoy/merge.d.ts.map +1 -1
  89. package/dist/cli/convoy/merge.js +18 -1
  90. package/dist/cli/convoy/merge.js.map +1 -1
  91. package/dist/cli/convoy/merge.test.js +6 -7
  92. package/dist/cli/convoy/merge.test.js.map +1 -1
  93. package/dist/cli/convoy/partition.d.ts +51 -0
  94. package/dist/cli/convoy/partition.d.ts.map +1 -0
  95. package/dist/cli/convoy/partition.js +186 -0
  96. package/dist/cli/convoy/partition.js.map +1 -0
  97. package/dist/cli/convoy/partition.test.d.ts +2 -0
  98. package/dist/cli/convoy/partition.test.d.ts.map +1 -0
  99. package/dist/cli/convoy/partition.test.js +315 -0
  100. package/dist/cli/convoy/partition.test.js.map +1 -0
  101. package/dist/cli/convoy/pipeline.test.js +6 -0
  102. package/dist/cli/convoy/pipeline.test.js.map +1 -1
  103. package/dist/cli/convoy/store.d.ts +47 -5
  104. package/dist/cli/convoy/store.d.ts.map +1 -1
  105. package/dist/cli/convoy/store.js +525 -19
  106. package/dist/cli/convoy/store.js.map +1 -1
  107. package/dist/cli/convoy/store.test.js +1345 -12
  108. package/dist/cli/convoy/store.test.js.map +1 -1
  109. package/dist/cli/convoy/types.d.ts +156 -2
  110. package/dist/cli/convoy/types.d.ts.map +1 -1
  111. package/dist/cli/destroy.d.ts +3 -0
  112. package/dist/cli/destroy.d.ts.map +1 -0
  113. package/dist/cli/destroy.js +69 -0
  114. package/dist/cli/destroy.js.map +1 -0
  115. package/dist/cli/destroy.test.d.ts +2 -0
  116. package/dist/cli/destroy.test.d.ts.map +1 -0
  117. package/dist/cli/destroy.test.js +116 -0
  118. package/dist/cli/destroy.test.js.map +1 -0
  119. package/dist/cli/gitignore.d.ts +9 -0
  120. package/dist/cli/gitignore.d.ts.map +1 -1
  121. package/dist/cli/gitignore.js +29 -0
  122. package/dist/cli/gitignore.js.map +1 -1
  123. package/dist/cli/plan.d.ts +3 -0
  124. package/dist/cli/plan.d.ts.map +1 -0
  125. package/dist/cli/plan.js +288 -0
  126. package/dist/cli/plan.js.map +1 -0
  127. package/dist/cli/run/adapters/claude.d.ts +2 -0
  128. package/dist/cli/run/adapters/claude.d.ts.map +1 -1
  129. package/dist/cli/run/adapters/claude.js +89 -49
  130. package/dist/cli/run/adapters/claude.js.map +1 -1
  131. package/dist/cli/run/adapters/claude.test.d.ts +2 -0
  132. package/dist/cli/run/adapters/claude.test.d.ts.map +1 -0
  133. package/dist/cli/run/adapters/claude.test.js +205 -0
  134. package/dist/cli/run/adapters/claude.test.js.map +1 -0
  135. package/dist/cli/run/adapters/copilot.d.ts +1 -0
  136. package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
  137. package/dist/cli/run/adapters/copilot.js +84 -46
  138. package/dist/cli/run/adapters/copilot.js.map +1 -1
  139. package/dist/cli/run/adapters/copilot.test.d.ts +2 -0
  140. package/dist/cli/run/adapters/copilot.test.d.ts.map +1 -0
  141. package/dist/cli/run/adapters/copilot.test.js +195 -0
  142. package/dist/cli/run/adapters/copilot.test.js.map +1 -0
  143. package/dist/cli/run/adapters/cursor.d.ts +1 -0
  144. package/dist/cli/run/adapters/cursor.d.ts.map +1 -1
  145. package/dist/cli/run/adapters/cursor.js +83 -47
  146. package/dist/cli/run/adapters/cursor.js.map +1 -1
  147. package/dist/cli/run/adapters/cursor.test.d.ts +2 -0
  148. package/dist/cli/run/adapters/cursor.test.d.ts.map +1 -0
  149. package/dist/cli/run/adapters/cursor.test.js +129 -0
  150. package/dist/cli/run/adapters/cursor.test.js.map +1 -0
  151. package/dist/cli/run/adapters/opencode.d.ts +1 -0
  152. package/dist/cli/run/adapters/opencode.d.ts.map +1 -1
  153. package/dist/cli/run/adapters/opencode.js +81 -47
  154. package/dist/cli/run/adapters/opencode.js.map +1 -1
  155. package/dist/cli/run/adapters/opencode.test.d.ts +2 -0
  156. package/dist/cli/run/adapters/opencode.test.d.ts.map +1 -0
  157. package/dist/cli/run/adapters/opencode.test.js +119 -0
  158. package/dist/cli/run/adapters/opencode.test.js.map +1 -0
  159. package/dist/cli/run/executor.js +1 -1
  160. package/dist/cli/run/executor.js.map +1 -1
  161. package/dist/cli/run/schema.d.ts.map +1 -1
  162. package/dist/cli/run/schema.js +245 -4
  163. package/dist/cli/run/schema.js.map +1 -1
  164. package/dist/cli/run/schema.test.js +669 -0
  165. package/dist/cli/run/schema.test.js.map +1 -1
  166. package/dist/cli/run.d.ts.map +1 -1
  167. package/dist/cli/run.js +362 -22
  168. package/dist/cli/run.js.map +1 -1
  169. package/dist/cli/types.d.ts +85 -2
  170. package/dist/cli/types.d.ts.map +1 -1
  171. package/dist/cli/types.js.map +1 -1
  172. package/dist/cli/watch.d.ts +15 -0
  173. package/dist/cli/watch.d.ts.map +1 -0
  174. package/dist/cli/watch.js +279 -0
  175. package/dist/cli/watch.js.map +1 -0
  176. package/package.json +1 -1
  177. package/src/cli/agents.ts +177 -0
  178. package/src/cli/baselines.ts +143 -0
  179. package/src/cli/convoy/engine.test.ts +1839 -70
  180. package/src/cli/convoy/engine.ts +2417 -38
  181. package/src/cli/convoy/events.test.ts +179 -38
  182. package/src/cli/convoy/events.ts +88 -16
  183. package/src/cli/convoy/expertise.test.ts +128 -0
  184. package/src/cli/convoy/expertise.ts +163 -0
  185. package/src/cli/convoy/export.test.ts +1 -0
  186. package/src/cli/convoy/formula.test.ts +405 -0
  187. package/src/cli/convoy/formula.ts +174 -0
  188. package/src/cli/convoy/gates.test.ts +1169 -0
  189. package/src/cli/convoy/gates.ts +774 -0
  190. package/src/cli/convoy/health.test.ts +64 -2
  191. package/src/cli/convoy/health.ts +80 -2
  192. package/src/cli/convoy/issues.test.ts +143 -0
  193. package/src/cli/convoy/issues.ts +136 -0
  194. package/src/cli/convoy/knowledge.test.ts +101 -0
  195. package/src/cli/convoy/knowledge.ts +132 -0
  196. package/src/cli/convoy/lessons.test.ts +188 -0
  197. package/src/cli/convoy/lessons.ts +164 -0
  198. package/src/cli/convoy/lock.test.ts +181 -0
  199. package/src/cli/convoy/lock.ts +103 -0
  200. package/src/cli/convoy/merge.test.ts +6 -7
  201. package/src/cli/convoy/merge.ts +19 -1
  202. package/src/cli/convoy/partition.test.ts +423 -0
  203. package/src/cli/convoy/partition.ts +232 -0
  204. package/src/cli/convoy/pipeline.test.ts +6 -0
  205. package/src/cli/convoy/store.test.ts +1512 -14
  206. package/src/cli/convoy/store.ts +676 -30
  207. package/src/cli/convoy/types.ts +170 -1
  208. package/src/cli/destroy.test.ts +141 -0
  209. package/src/cli/destroy.ts +88 -0
  210. package/src/cli/gitignore.ts +36 -0
  211. package/src/cli/plan.ts +316 -0
  212. package/src/cli/run/adapters/claude.test.ts +234 -0
  213. package/src/cli/run/adapters/claude.ts +45 -5
  214. package/src/cli/run/adapters/copilot.test.ts +224 -0
  215. package/src/cli/run/adapters/copilot.ts +34 -4
  216. package/src/cli/run/adapters/cursor.test.ts +144 -0
  217. package/src/cli/run/adapters/cursor.ts +33 -2
  218. package/src/cli/run/adapters/opencode.test.ts +135 -0
  219. package/src/cli/run/adapters/opencode.ts +30 -2
  220. package/src/cli/run/executor.ts +1 -1
  221. package/src/cli/run/schema.test.ts +758 -0
  222. package/src/cli/run/schema.ts +300 -25
  223. package/src/cli/run.ts +341 -21
  224. package/src/cli/types.ts +86 -1
  225. package/src/cli/watch.ts +298 -0
  226. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
@@ -0,0 +1,774 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import { inflateSync } from 'node:zlib'
6
+ import { parse as yamlParse } from 'yaml'
7
+ import type { BrowserTestConfig, MCPServerConfig } from './types.js'
8
+
9
+ // ── Secret patterns ───────────────────────────────────────────────────────────
10
+
11
+ interface SecretPatternEntry {
12
+ name: string
13
+ pattern: RegExp
14
+ }
15
+
16
+ const SECRET_PATTERNS: SecretPatternEntry[] = [
17
+ { name: 'AWS Access Key', pattern: /AKIA[0-9A-Z]{16}/i },
18
+ {
19
+ name: 'AWS Secret Key',
20
+ // eslint-disable-next-line no-useless-escape
21
+ pattern: /(?:aws_secret_access_key|secret_key)\s*[=:]\s*[A-Za-z0-9\/+=]{40}/i,
22
+ },
23
+ {
24
+ name: 'Generic API Key',
25
+ pattern: /(?:api[_-]?key|apikey)\s*[=:]\s*['"]?[A-Za-z0-9_-]{20,}/i,
26
+ },
27
+ { name: 'Bearer Token', pattern: /[Bb]earer\s+[A-Za-z0-9\-._~+/]+=*/ },
28
+ { name: 'Private Key', pattern: /-----BEGIN (?:RSA|EC|OPENSSH) PRIVATE KEY-----/ },
29
+ {
30
+ name: 'Connection String',
31
+ // eslint-disable-next-line no-useless-escape
32
+ pattern: /(?:postgres|mysql|mongodb|redis):\/\/[^\s]+:[^\s]+@/,
33
+ },
34
+ { name: 'GitHub Token', pattern: /(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,}/ },
35
+ {
36
+ name: 'Generic Password',
37
+ pattern: /(?:password|passwd|pwd)\s*[=:]\s*['"]?[^\s'"]{8,}/i,
38
+ },
39
+ { name: 'Slack Token', pattern: /xox[bprs]-[A-Za-z0-9-]{10,}/i },
40
+ {
41
+ name: 'Generic Secret',
42
+ pattern: /(?:secret|token|credential)\s*[=:]\s*['"]?[A-Za-z0-9_-]{16,}/i,
43
+ },
44
+ ]
45
+
46
+ // ── Public types ──────────────────────────────────────────────────────────────
47
+
48
+ export interface SecretScanResult {
49
+ clean: boolean
50
+ findings: Array<{ pattern: string; file: string; line: number; snippet: string }>
51
+ }
52
+
53
+ // ── Allowlist ─────────────────────────────────────────────────────────────────
54
+
55
+ interface AllowlistEntry {
56
+ pattern?: string
57
+ literal?: string
58
+ reason: string
59
+ paths?: string[]
60
+ }
61
+
62
+ let _allowlist: AllowlistEntry[] | null = null
63
+
64
+ /** The config path used for the allowlist. Override for testing. */
65
+ export let _allowlistConfigPath = join(process.cwd(), '.opencastle', 'secret-scan-config.yml')
66
+
67
+ /** Reset the allowlist cache (for testing). */
68
+ export function _resetAllowlistCache(): void {
69
+ _allowlist = null
70
+ }
71
+
72
+ /** Override the allowlist config path and reset cache (for testing). */
73
+ export function _setAllowlistConfigPath(path: string): void {
74
+ _allowlistConfigPath = path
75
+ _allowlist = null
76
+ }
77
+
78
+ function loadAllowlist(): AllowlistEntry[] {
79
+ if (_allowlist !== null) return _allowlist
80
+ try {
81
+ if (!existsSync(_allowlistConfigPath)) {
82
+ _allowlist = []
83
+ return _allowlist
84
+ }
85
+ const content = readFileSync(_allowlistConfigPath, 'utf-8')
86
+ const parsed = yamlParse(content) as Record<string, unknown> | null
87
+ if (!parsed || !Array.isArray(parsed['allowlist'])) {
88
+ _allowlist = []
89
+ return _allowlist
90
+ }
91
+ _allowlist = parsed['allowlist'] as AllowlistEntry[]
92
+ return _allowlist
93
+ } catch {
94
+ _allowlist = []
95
+ return _allowlist
96
+ }
97
+ }
98
+
99
+ function isSuppressed(
100
+ finding: { snippet: string },
101
+ filePath: string,
102
+ allowlist: AllowlistEntry[],
103
+ ): boolean {
104
+ for (const entry of allowlist) {
105
+ if (entry.paths && entry.paths.length > 0) {
106
+ if (!entry.paths.some((p) => filePath.includes(p))) continue
107
+ }
108
+ if (entry.literal) {
109
+ if (finding.snippet.includes(entry.literal)) return true
110
+ } else if (entry.pattern) {
111
+ try {
112
+ if (new RegExp(entry.pattern, 'i').test(finding.snippet)) return true
113
+ } catch {
114
+ // Invalid regex in allowlist — skip entry
115
+ }
116
+ }
117
+ }
118
+ return false
119
+ }
120
+
121
+ // ── scanForSecrets ────────────────────────────────────────────────────────────
122
+
123
+ /**
124
+ * Scan text content line-by-line for secrets using the default pattern set.
125
+ * Allowlist entries in `.opencastle/secret-scan-config.yml` suppress false positives.
126
+ */
127
+ export function scanForSecrets(content: string, filePath = ''): SecretScanResult {
128
+ const allowlist = loadAllowlist()
129
+ const lines = content.split('\n')
130
+ const findings: SecretScanResult['findings'] = []
131
+
132
+ for (let i = 0; i < lines.length; i++) {
133
+ const line = lines[i]
134
+ for (const { name, pattern } of SECRET_PATTERNS) {
135
+ if (pattern.test(line)) {
136
+ const snippet = line.length > 100 ? line.slice(0, 97) + '...' : line
137
+ const finding = { pattern: name, file: filePath, line: i + 1, snippet }
138
+ if (!isSuppressed(finding, filePath, allowlist)) {
139
+ findings.push(finding)
140
+ }
141
+ // Only report the first matching pattern per line
142
+ break
143
+ }
144
+ }
145
+ }
146
+
147
+ return { clean: findings.length === 0, findings }
148
+ }
149
+
150
+ // ── withSecretScan ────────────────────────────────────────────────────────────
151
+
152
+ /**
153
+ * Guard a write action with a secret scan.
154
+ * Calls `writeAction` if content is clean; calls `onBlock` with findings otherwise.
155
+ */
156
+ export function withSecretScan(
157
+ content: string,
158
+ writeAction: () => void,
159
+ onBlock: (findings: SecretScanResult['findings']) => void,
160
+ ): void {
161
+ const result = scanForSecrets(content)
162
+ if (result.clean) {
163
+ writeAction()
164
+ } else {
165
+ onBlock(result.findings)
166
+ }
167
+ }
168
+
169
+ // ── Gate command runner ───────────────────────────────────────────────────────
170
+
171
+ export interface GateCommandResult {
172
+ stdout: string
173
+ stderr: string
174
+ exitCode: number
175
+ timedOut: boolean
176
+ }
177
+
178
+ /**
179
+ * Run a shell command with SIGTERM → SIGKILL timeout escalation.
180
+ * On timeout: sends SIGTERM immediately, then SIGKILL after 5 s if still running.
181
+ */
182
+ export function runGateCommand(
183
+ command: string,
184
+ args: string[],
185
+ cwd: string,
186
+ timeoutMs = 300_000,
187
+ ): Promise<GateCommandResult> {
188
+ return new Promise((resolve) => {
189
+ const child = spawn(command, args, { cwd, stdio: 'pipe' })
190
+ let stdout = ''
191
+ let stderr = ''
192
+ let settled = false
193
+ let timedOut = false
194
+ let sigkillTimer: ReturnType<typeof setTimeout> | null = null
195
+
196
+ child.stdout?.on('data', (d: Buffer) => {
197
+ stdout += d.toString()
198
+ })
199
+ child.stderr?.on('data', (d: Buffer) => {
200
+ stderr += d.toString()
201
+ })
202
+
203
+ const timer = setTimeout(() => {
204
+ if (settled) return
205
+ timedOut = true
206
+ child.kill('SIGTERM')
207
+ sigkillTimer = setTimeout(() => {
208
+ if (!settled) child.kill('SIGKILL')
209
+ }, 5_000)
210
+ }, timeoutMs)
211
+
212
+ function settle(exitCode: number): void {
213
+ if (settled) return
214
+ settled = true
215
+ clearTimeout(timer)
216
+ if (sigkillTimer !== null) clearTimeout(sigkillTimer)
217
+ resolve({ stdout, stderr, exitCode, timedOut })
218
+ }
219
+
220
+ child.on('close', (code: number | null) => settle(code ?? -1))
221
+ child.on('error', () => settle(-1))
222
+ })
223
+ }
224
+
225
+ // ── runSecretScanGate ─────────────────────────────────────────────────────────
226
+
227
+ /** Scan a list of changed files in the worktree for secrets. */
228
+ export async function runSecretScanGate(
229
+ changedFiles: string[],
230
+ worktreePath: string,
231
+ ): Promise<{ passed: boolean; output: string }> {
232
+ const allFindings: SecretScanResult['findings'] = []
233
+
234
+ for (const relPath of changedFiles) {
235
+ const fullPath = join(worktreePath, relPath)
236
+ let content: string
237
+ try {
238
+ content = await readFile(fullPath, 'utf-8')
239
+ } catch {
240
+ continue // Skip unreadable files (deleted, binary, etc.)
241
+ }
242
+ const result = scanForSecrets(content, relPath)
243
+ allFindings.push(...result.findings)
244
+ }
245
+
246
+ if (allFindings.length === 0) {
247
+ return { passed: true, output: `Secret scan: clean (${changedFiles.length} files scanned)` }
248
+ }
249
+
250
+ const lines = allFindings.map((f) => ` [${f.pattern}] ${f.file}:${f.line}: ${f.snippet}`)
251
+ return {
252
+ passed: false,
253
+ output: `Secret scan: ${allFindings.length} finding(s) detected\n${lines.join('\n')}`,
254
+ }
255
+ }
256
+
257
+ // ── runBlastRadiusGate ────────────────────────────────────────────────────────
258
+
259
+ /**
260
+ * Analyze a git diff for blast radius.
261
+ * WARN at 200+ lines OR 5+ files; BLOCK at 500+ lines OR 10+ files.
262
+ */
263
+ export function runBlastRadiusGate(diff: string): {
264
+ passed: boolean
265
+ output: string
266
+ level: 'ok' | 'warn' | 'block'
267
+ } {
268
+ const diffLines = diff.split('\n')
269
+ let linesChanged = 0
270
+ let filesChanged = 0
271
+
272
+ for (const line of diffLines) {
273
+ if (line.startsWith('diff --git ')) {
274
+ filesChanged++
275
+ } else if (
276
+ (line.startsWith('+') && !line.startsWith('+++')) ||
277
+ (line.startsWith('-') && !line.startsWith('---'))
278
+ ) {
279
+ linesChanged++
280
+ }
281
+ }
282
+
283
+ const summary = `Blast radius: ${linesChanged} lines changed, ${filesChanged} files changed`
284
+
285
+ if (linesChanged >= 500 || filesChanged >= 10) {
286
+ return {
287
+ passed: false,
288
+ output: `${summary} — exceeds BLOCK threshold (500+ lines or 10+ files)`,
289
+ level: 'block',
290
+ }
291
+ }
292
+ if (linesChanged >= 200 || filesChanged >= 5) {
293
+ return {
294
+ passed: true,
295
+ output: `${summary} — exceeds WARN threshold (200+ lines or 5+ files)`,
296
+ level: 'warn',
297
+ }
298
+ }
299
+ return { passed: true, output: `${summary} — within acceptable limits`, level: 'ok' }
300
+ }
301
+
302
+ // ── runDependencyAuditGate ────────────────────────────────────────────────────
303
+
304
+ /** Run npm audit in the worktree to detect high/critical vulnerabilities. */
305
+ export async function runDependencyAuditGate(
306
+ worktreePath: string,
307
+ ): Promise<{ passed: boolean; output: string }> {
308
+ const result = await runGateCommand('npm', ['audit', '--json'], worktreePath, 300_000)
309
+ if (result.exitCode === 0) {
310
+ return { passed: true, output: 'Dependency audit: no vulnerabilities found' }
311
+ }
312
+ try {
313
+ const auditData = JSON.parse(result.stdout) as {
314
+ metadata?: { vulnerabilities?: { critical?: number; high?: number } }
315
+ }
316
+ const vulns = auditData.metadata?.vulnerabilities
317
+ if (vulns) {
318
+ const critical = vulns.critical ?? 0
319
+ const high = vulns.high ?? 0
320
+ if (critical > 0 || high > 0) {
321
+ return {
322
+ passed: false,
323
+ output: `Dependency audit failed: ${critical} critical, ${high} high vulnerabilities\n${result.stdout}`,
324
+ }
325
+ }
326
+ }
327
+ } catch {
328
+ // Fall through if JSON parse fails
329
+ }
330
+ return {
331
+ passed: false,
332
+ output: `Dependency audit failed (exit ${result.exitCode}):\n${result.stderr || result.stdout}`,
333
+ }
334
+ }
335
+
336
+ // ── runRegressionTestGate ─────────────────────────────────────────────────────
337
+
338
+ /** Run the test suite in the worktree directory. */
339
+ export async function runRegressionTestGate(
340
+ worktreePath: string,
341
+ testCommand = 'npm test',
342
+ ): Promise<{ passed: boolean; output: string }> {
343
+ const parts = testCommand.split(' ')
344
+ const cmd = parts[0]
345
+ const args = parts.slice(1)
346
+ const result = await runGateCommand(cmd, args, worktreePath, 300_000)
347
+ if (result.exitCode === 0) {
348
+ return { passed: true, output: `Regression test passed\n${result.stdout}` }
349
+ }
350
+ return {
351
+ passed: false,
352
+ output: `Regression test failed (exit ${result.exitCode}):\n${result.stderr || result.stdout}`,
353
+ }
354
+ }
355
+
356
+ // ── PNG pixel diff ───────────────────────────────────────────────────────────
357
+
358
+ function parsePngDimensions(
359
+ buf: Buffer,
360
+ ): { width: number; height: number; colorType: number } | null {
361
+ if (buf.length < 29) return null
362
+ const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
363
+ for (let i = 0; i < 8; i++) {
364
+ if (buf[i] !== sig[i]) return null
365
+ }
366
+ const chunkType = buf.slice(12, 16).toString('ascii')
367
+ if (chunkType !== 'IHDR') return null
368
+ const width = buf.readUInt32BE(16)
369
+ const height = buf.readUInt32BE(20)
370
+ const colorType = buf[25]
371
+ return { width, height, colorType }
372
+ }
373
+
374
+ function collectIdatData(buf: Buffer): Buffer {
375
+ const chunks: Buffer[] = []
376
+ let offset = 8
377
+ while (offset + 12 <= buf.length) {
378
+ const length = buf.readUInt32BE(offset)
379
+ const type = buf.slice(offset + 4, offset + 8).toString('ascii')
380
+ if (type === 'IEND') break
381
+ if (type === 'IDAT') {
382
+ chunks.push(buf.slice(offset + 8, offset + 8 + length))
383
+ }
384
+ offset += 8 + length + 4
385
+ }
386
+ return Buffer.concat(chunks)
387
+ }
388
+
389
+ function bytesPerPixelForColorType(colorType: number): number {
390
+ switch (colorType) {
391
+ case 0: return 1
392
+ case 2: return 3
393
+ case 3: return 1
394
+ case 4: return 2
395
+ case 6: return 4
396
+ default: return 4
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Compare two PNG buffers pixel-by-pixel and return the fraction (0–1) of differing pixels.
402
+ * Returns 1.0 if dimensions differ, buffers are invalid, or an error occurs.
403
+ */
404
+ export function pixelDiffPercentage(baselineBuffer: Buffer, currentBuffer: Buffer): number {
405
+ try {
406
+ const baseDims = parsePngDimensions(baselineBuffer)
407
+ const currDims = parsePngDimensions(currentBuffer)
408
+ if (!baseDims || !currDims) return 1.0
409
+ if (baseDims.width !== currDims.width || baseDims.height !== currDims.height) return 1.0
410
+ const { width, height, colorType } = baseDims
411
+ const totalPixels = width * height
412
+ if (totalPixels === 0) return 0
413
+ const baseIdat = collectIdatData(baselineBuffer)
414
+ const currIdat = collectIdatData(currentBuffer)
415
+ const baseRaw = inflateSync(baseIdat)
416
+ const currRaw = inflateSync(currIdat)
417
+ const bpp = bytesPerPixelForColorType(colorType)
418
+ const rowBytes = 1 + width * bpp
419
+ let diffPixels = 0
420
+ for (let row = 0; row < height; row++) {
421
+ const rowOffset = row * rowBytes
422
+ for (let col = 0; col < width; col++) {
423
+ const pixelOffset = rowOffset + 1 + col * bpp
424
+ let differs = false
425
+ for (let channel = 0; channel < bpp; channel++) {
426
+ const base = baseRaw[pixelOffset + channel] ?? 0
427
+ const curr = currRaw[pixelOffset + channel] ?? 0
428
+ if (Math.abs(base - curr) > 5) {
429
+ differs = true
430
+ break
431
+ }
432
+ }
433
+ if (differs) diffPixels++
434
+ }
435
+ }
436
+ return diffPixels / totalPixels
437
+ } catch {
438
+ return 1.0
439
+ }
440
+ }
441
+
442
+ export interface VisualDiffContext {
443
+ screenshotBuffer: Buffer
444
+ baselinePath: string
445
+ threshold: number
446
+ }
447
+
448
+ export interface VisualDiffResult {
449
+ passed: boolean
450
+ diffPercent: number
451
+ output: string
452
+ }
453
+
454
+ /** Compare a screenshot buffer against a saved baseline PNG. */
455
+ export async function computeVisualDiff(context: VisualDiffContext): Promise<VisualDiffResult> {
456
+ const { screenshotBuffer, baselinePath, threshold } = context
457
+ if (!existsSync(baselinePath)) {
458
+ return { passed: true, diffPercent: 0, output: 'No baseline found — skipping visual diff' }
459
+ }
460
+ const scanResult = scanForSecrets(screenshotBuffer.toString('base64'), 'screenshot')
461
+ if (!scanResult.clean) {
462
+ return {
463
+ passed: false,
464
+ diffPercent: 1.0,
465
+ output: 'Screenshot scan: potential secrets detected in screenshot data',
466
+ }
467
+ }
468
+ const baselineBuffer = readFileSync(baselinePath)
469
+ const diffPercent = pixelDiffPercentage(baselineBuffer, screenshotBuffer)
470
+ const passed = diffPercent <= threshold
471
+ return {
472
+ passed,
473
+ diffPercent,
474
+ output: passed
475
+ ? `Visual diff: PASS (${(diffPercent * 100).toFixed(2)}% diff, threshold: ${
476
+ (threshold * 100).toFixed(2)
477
+ }%)`
478
+ : `Visual diff: FAIL (${(diffPercent * 100).toFixed(2)}% diff exceeds threshold: ${
479
+ (threshold * 100).toFixed(2)
480
+ }%)`,
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Persist a screenshot buffer as a PNG baseline file.
486
+ * Secret-scans the buffer (as base64) before writing.
487
+ */
488
+ export function captureAndPersistBaseline(
489
+ screenshotBuffer: Buffer,
490
+ slug: string,
491
+ basePath = '.opencastle/baselines',
492
+ ): { persisted: boolean; reason?: string } {
493
+ const scanResult = scanForSecrets(screenshotBuffer.toString('base64'), 'screenshot')
494
+ if (!scanResult.clean) {
495
+ return { persisted: false, reason: 'secrets_detected' }
496
+ }
497
+ mkdirSync(basePath, { recursive: true })
498
+ writeFileSync(join(basePath, `${slug}.png`), screenshotBuffer)
499
+ return { persisted: true }
500
+ }
501
+
502
+ // ── A11y audit ────────────────────────────────────────────────────────────────
503
+
504
+ export type A11ySeverity = 'critical' | 'serious' | 'moderate' | 'minor'
505
+
506
+ const A11Y_SEVERITY_RANK: Record<A11ySeverity, number> = {
507
+ critical: 4,
508
+ serious: 3,
509
+ moderate: 2,
510
+ minor: 1,
511
+ }
512
+
513
+ export interface A11yFinding {
514
+ id: string
515
+ impact: A11ySeverity
516
+ description: string
517
+ nodes: number
518
+ }
519
+
520
+ /**
521
+ * Map a list of a11y findings against a severity threshold.
522
+ * Returns passed:false if any finding meets or exceeds the threshold.
523
+ */
524
+ export function mapA11ySeverity(
525
+ findings: A11yFinding[],
526
+ threshold: A11ySeverity,
527
+ ): { passed: boolean; output: string; findings: A11yFinding[] } {
528
+ const thresholdRank = A11Y_SEVERITY_RANK[threshold]
529
+ const failing = findings.filter((f) => A11Y_SEVERITY_RANK[f.impact] >= thresholdRank)
530
+ if (failing.length === 0) {
531
+ return {
532
+ passed: true,
533
+ output: `A11y audit: PASS (0 findings at or above ${threshold} severity)`,
534
+ findings: [],
535
+ }
536
+ }
537
+ const countBySeverity: Record<string, number> = {}
538
+ for (const f of failing) {
539
+ countBySeverity[f.impact] = (countBySeverity[f.impact] ?? 0) + 1
540
+ }
541
+ const countSummary = Object.entries(countBySeverity)
542
+ .map(([k, v]) => `${v} ${k}`)
543
+ .join(', ')
544
+ const descriptions = failing
545
+ .map((f) => ` [${f.impact}] ${f.id}: ${f.description.slice(0, 200)}`)
546
+ .join('\n')
547
+ return {
548
+ passed: false,
549
+ output: `A11y audit: FAIL (${countSummary})\n${descriptions}`,
550
+ findings: failing,
551
+ }
552
+ }
553
+
554
+ export interface A11yAuditContext {
555
+ mcpServers: MCPServerConfig[]
556
+ url: string
557
+ severityThreshold: A11ySeverity
558
+ }
559
+
560
+ /** Run an a11y audit via a browser-capable MCP server. */
561
+ export async function runA11yAudit(
562
+ context: A11yAuditContext,
563
+ ): Promise<{ passed: boolean; output: string; findings: A11yFinding[] }> {
564
+ const { mcpServers, url, severityThreshold } = context
565
+ if (!isLocalUrl(url)) {
566
+ return {
567
+ passed: false,
568
+ output: `A11y audit blocked: URL "${url}" is not a local address. Only localhost/127.0.0.1/[::1] URLs are allowed.`,
569
+ findings: [],
570
+ }
571
+ }
572
+ const browserServer = mcpServers.find(
573
+ (s) =>
574
+ /browser|chrome|playwright|devtools/i.test(s.name) ||
575
+ /browser|chrome|playwright|devtools/i.test(s.type),
576
+ )
577
+ if (!browserServer?.command) {
578
+ return {
579
+ passed: false,
580
+ output: 'A11y audit: no browser-capable MCP server found',
581
+ findings: [],
582
+ }
583
+ }
584
+ const result = await runGateCommand(
585
+ browserServer.command,
586
+ [...(browserServer.args ?? []), '--a11y-audit', '--urls', url],
587
+ process.cwd(),
588
+ 60_000,
589
+ )
590
+ const scanResult = scanForSecrets(result.stdout, 'a11y-audit-output')
591
+ if (!scanResult.clean) {
592
+ return {
593
+ passed: false,
594
+ output: 'A11y audit: output contained potential secrets (redacted)',
595
+ findings: [],
596
+ }
597
+ }
598
+ let findings: A11yFinding[] = []
599
+ try {
600
+ findings = JSON.parse(result.stdout) as A11yFinding[]
601
+ } catch {
602
+ return {
603
+ passed: result.exitCode === 0,
604
+ output:
605
+ result.exitCode === 0
606
+ ? 'A11y audit: PASS (no structured output)'
607
+ : `A11y audit: failed to parse output (exit ${result.exitCode}): ${
608
+ result.stderr || result.stdout
609
+ }`,
610
+ findings: [],
611
+ }
612
+ }
613
+ return mapA11ySeverity(findings, severityThreshold)
614
+ }
615
+
616
+ // ── browserTestGate ───────────────────────────────────────────────────────────
617
+
618
+ /**
619
+ * Validate that a URL points to localhost/127.0.0.1/[::1] only.
620
+ * Prevents SSRF by rejecting any external addresses.
621
+ */
622
+ function isLocalUrl(url: string): boolean {
623
+ try {
624
+ const parsed = new URL(url)
625
+ const host = parsed.hostname
626
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'
627
+ } catch {
628
+ return false
629
+ }
630
+ }
631
+
632
+ export interface BrowserTestContext {
633
+ mcpServers: MCPServerConfig[]
634
+ taskConfig: BrowserTestConfig
635
+ worktreePath: string
636
+ approvalTimeout?: number
637
+ }
638
+
639
+ /**
640
+ * Run browser-based tests against localhost URLs using an MCP browser server.
641
+ * All URLs are validated to be local addresses (SSRF prevention).
642
+ */
643
+ export async function browserTestGate(
644
+ context: BrowserTestContext,
645
+ ): Promise<{ passed: boolean; output: string }> {
646
+ const { mcpServers, taskConfig, worktreePath } = context
647
+ const results: string[] = []
648
+ let allPassed = true
649
+
650
+ // Validate URLs — only allow localhost to prevent SSRF
651
+ for (const url of taskConfig.urls) {
652
+ if (!isLocalUrl(url)) {
653
+ return {
654
+ passed: false,
655
+ output: `Browser test gate blocked: URL "${url}" is not a local address. Only localhost/127.0.0.1/[::1] URLs are allowed.`,
656
+ }
657
+ }
658
+ }
659
+
660
+ // Find browser-capable MCP server
661
+ const browserServer = mcpServers.find(
662
+ (s) =>
663
+ /browser|chrome|playwright|devtools/i.test(s.name) ||
664
+ /browser|chrome|playwright|devtools/i.test(s.type),
665
+ )
666
+
667
+ if (!browserServer) {
668
+ return {
669
+ passed: false,
670
+ output: 'Browser test gate: no browser-capable MCP server found in defaults.mcp_servers',
671
+ }
672
+ }
673
+
674
+ // Test each URL via curl
675
+ for (const url of taskConfig.urls) {
676
+ const curlResult = await runGateCommand(
677
+ 'curl',
678
+ ['-sS', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '30', url],
679
+ worktreePath,
680
+ 35_000,
681
+ )
682
+
683
+ if (curlResult.timedOut) {
684
+ allPassed = false
685
+ results.push(` \u2717 ${url}: timed out`)
686
+ } else {
687
+ const statusCode = parseInt(curlResult.stdout.trim(), 10)
688
+ if (isNaN(statusCode) || statusCode >= 400) {
689
+ allPassed = false
690
+ results.push(` \u2717 ${url}: HTTP ${curlResult.stdout.trim() || 'error'} (exit ${curlResult.exitCode})`)
691
+ } else {
692
+ results.push(` \u2713 ${url}: HTTP ${statusCode}`)
693
+ }
694
+ }
695
+ }
696
+
697
+ // If browser MCP server has a command, attempt browser automation
698
+ if (browserServer.command) {
699
+ const browserArgs = [...(browserServer.args ?? []), '--urls', ...taskConfig.urls]
700
+ if (taskConfig.check_console_errors) browserArgs.push('--check-console-errors')
701
+
702
+ const timeoutMs = (context.approvalTimeout ?? 60) * 1000
703
+ const browserResult = await runGateCommand(
704
+ browserServer.command,
705
+ browserArgs,
706
+ worktreePath,
707
+ timeoutMs,
708
+ )
709
+
710
+ if (browserResult.exitCode !== 0) {
711
+ allPassed = false
712
+ results.push(
713
+ ` Browser automation failed (exit ${browserResult.exitCode}): ${browserResult.stderr || browserResult.stdout}`,
714
+ )
715
+ } else {
716
+ const scanResult = scanForSecrets(browserResult.stdout, 'browser-test-output')
717
+ if (!scanResult.clean) {
718
+ results.push(` \u26a0 Browser output contained potential secrets (redacted)`)
719
+ } else {
720
+ results.push(` Browser automation passed`)
721
+ if (taskConfig.check_console_errors && browserResult.stdout.includes('[console.error]')) {
722
+ allPassed = false
723
+ results.push(` \u2717 Console errors detected in browser output`)
724
+ }
725
+ }
726
+ }
727
+ }
728
+
729
+ // Visual diff check (if visual_diff_threshold is set and browser server has a command)
730
+ if (taskConfig.visual_diff_threshold !== undefined && browserServer?.command) {
731
+ const baselinesDir = join(worktreePath, taskConfig.baselines_dir ?? '.opencastle/baselines')
732
+ for (const url of taskConfig.urls) {
733
+ const slug = url.replace(/[^a-zA-Z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
734
+ const ssResult = await runGateCommand(
735
+ browserServer.command,
736
+ [...(browserServer.args ?? []), '--screenshot', '--urls', url],
737
+ worktreePath,
738
+ (context.approvalTimeout ?? 60) * 1000,
739
+ )
740
+ if (ssResult.exitCode !== 0) {
741
+ allPassed = false
742
+ results.push(` Visual diff ${url}: screenshot failed (exit ${ssResult.exitCode})`)
743
+ continue
744
+ }
745
+ const screenshotBuffer = Buffer.from(ssResult.stdout)
746
+ const baselinePath = join(baselinesDir, `${slug}.png`)
747
+ const diffResult = await computeVisualDiff({
748
+ screenshotBuffer,
749
+ baselinePath,
750
+ threshold: taskConfig.visual_diff_threshold,
751
+ })
752
+ results.push(` Visual diff ${url}: ${diffResult.output}`)
753
+ if (!diffResult.passed) allPassed = false
754
+ }
755
+ }
756
+
757
+ // A11y audit (if a11y is enabled)
758
+ if (taskConfig.a11y) {
759
+ for (const url of taskConfig.urls) {
760
+ const a11yResult = await runA11yAudit({
761
+ mcpServers,
762
+ url,
763
+ severityThreshold: taskConfig.severity_threshold ?? 'serious',
764
+ })
765
+ results.push(` A11y ${url}: ${a11yResult.output}`)
766
+ if (!a11yResult.passed) allPassed = false
767
+ }
768
+ }
769
+
770
+ return {
771
+ passed: allPassed,
772
+ output: `Browser test gate: ${allPassed ? 'PASS' : 'FAIL'}\n${results.join('\n')}`,
773
+ }
774
+ }