@soulbatical/tetra-dev-toolkit 1.20.2 → 2.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.
@@ -1,317 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * Tetra Migration Lint — HARD BLOCK before supabase db push
5
- *
6
- * Scans SQL migration files OFFLINE (no Supabase connection needed) for:
7
- * 1. SECURITY DEFINER on data RPCs (must be INVOKER)
8
- * 2. Tables without ENABLE ROW LEVEL SECURITY
9
- * 3. DROP POLICY / DROP TABLE without explicit confirmation
10
- * 4. GRANT to public/anon on sensitive tables
11
- * 5. Missing WITH CHECK on INSERT/UPDATE policies
12
- *
13
- * Usage:
14
- * tetra-migration-lint # Lint all migrations
15
- * tetra-migration-lint --staged # Only git-staged .sql files (for pre-commit)
16
- * tetra-migration-lint --file path.sql # Lint a specific file
17
- * tetra-migration-lint --fix-suggestions # Show fix SQL for each violation
18
- * tetra-migration-lint --json # JSON output for CI
19
- *
20
- * Exit codes:
21
- * 0 = all clean
22
- * 1 = violations found (CRITICAL or HIGH)
23
- * 2 = warnings only (MEDIUM/LOW) — does not block
24
- *
25
- * Hook usage (.husky/pre-commit):
26
- * tetra-migration-lint --staged || exit 1
27
- *
28
- * Wrapper usage (replace `supabase db push`):
29
- * tetra-migration-lint && supabase db push
30
- */
31
-
32
- import { readFileSync, existsSync } from 'fs'
33
- import { join, resolve, basename, relative } from 'path'
34
- import { globSync } from 'glob'
35
- import { execSync } from 'child_process'
36
- import chalk from 'chalk'
37
- import { program } from 'commander'
38
-
39
- // ─── Dangerous patterns ────────────────────────────────────────────
40
- const RULES = [
41
- {
42
- id: 'DEFINER_DATA_RPC',
43
- severity: 'critical',
44
- pattern: /CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+(?:public\.)?(\w+)\s*\([^)]*\)[\s\S]*?SECURITY\s+DEFINER/gi,
45
- test: (match, fullContent) => {
46
- const funcName = match[1]
47
- // Auth helpers are OK as DEFINER
48
- const authWhitelist = [
49
- // Auth helpers (need DEFINER to read auth schema)
50
- 'auth_org_id', 'auth_uid', 'auth_role', 'auth_user_role',
51
- 'auth_admin_organizations', 'auth_user_id', 'requesting_user_id',
52
- 'get_auth_org_id', 'get_current_user_id',
53
- 'auth_user_organizations', 'auth_creator_organizations',
54
- 'auth_organization_id', 'auth_is_admin', 'auth_is_superadmin',
55
- // Public RPCs (anon access, DEFINER needed to bypass RLS and return safe columns)
56
- 'search_public_ad_library',
57
- // System/billing RPCs (no user context)
58
- 'get_org_credit_limits',
59
- // Supabase internal
60
- 'handle_new_user', 'moddatetime'
61
- ]
62
- return !authWhitelist.includes(funcName)
63
- },
64
- message: (match) => `SECURITY DEFINER on RPC "${match[1]}" — bypasses RLS completely. Use SECURITY INVOKER instead.`,
65
- fix: (match) => `ALTER FUNCTION ${match[1]} SECURITY INVOKER;`
66
- },
67
- {
68
- id: 'CREATE_TABLE_NO_RLS',
69
- severity: 'critical',
70
- // Match CREATE TABLE that is NOT followed by ENABLE ROW LEVEL SECURITY in the same migration
71
- test: (match, fullContent) => {
72
- const tableName = match[1]
73
- const rlsPattern = new RegExp(`ALTER\\s+TABLE\\s+(?:public\\.)?${tableName}\\s+ENABLE\\s+ROW\\s+LEVEL\\s+SECURITY`, 'i')
74
- return !rlsPattern.test(fullContent)
75
- },
76
- pattern: /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:public\.)?(\w+)\s*\(/gi,
77
- message: (match) => `CREATE TABLE "${match[1]}" without ENABLE ROW LEVEL SECURITY in same migration.`,
78
- fix: (match) => `ALTER TABLE ${match[1]} ENABLE ROW LEVEL SECURITY;`
79
- },
80
- {
81
- id: 'DROP_POLICY',
82
- severity: 'high',
83
- pattern: /DROP\s+POLICY\s+(?:IF\s+EXISTS\s+)?["']?(\w+)["']?\s+ON\s+(?:public\.)?(\w+)/gi,
84
- message: (match) => `DROP POLICY "${match[1]}" on "${match[2]}" — removes security. Ensure a replacement policy exists in the same migration.`,
85
- test: (match, fullContent) => {
86
- const table = match[2]
87
- // OK if there's a CREATE POLICY in the same migration for the same table
88
- const createPattern = new RegExp(`CREATE\\s+POLICY.*ON\\s+(?:public\\.)?${table}`, 'i')
89
- if (createPattern.test(fullContent)) return false
90
- // OK if there's a DO $$ block that references the table (dynamic policy creation)
91
- const doBlockPattern = new RegExp(`DO\\s+\\$\\$[\\s\\S]*?['"]${table}['"][\\s\\S]*?\\$\\$`, 'i')
92
- if (doBlockPattern.test(fullContent)) return false
93
- // OK if DROP POLICY IF EXISTS (safe — only drops if present)
94
- const dropIfExists = new RegExp(`DROP\\s+POLICY\\s+IF\\s+EXISTS`, 'i')
95
- const matchStr = match[0]
96
- if (dropIfExists.test(matchStr)) {
97
- // Check if ANY policy creation exists for this table (even dynamic)
98
- const anyCreate = new RegExp(`(CREATE\\s+POLICY|EXECUTE\\s+format.*CREATE\\s+POLICY)`, 'i')
99
- if (anyCreate.test(fullContent)) return false
100
- }
101
- return true
102
- },
103
- fix: (match) => `-- Add replacement policy:\n-- CREATE POLICY "${match[1]}" ON ${match[2]} FOR ALL USING (organization_id = auth_org_id());`
104
- },
105
- {
106
- id: 'GRANT_PUBLIC_ANON',
107
- severity: 'critical',
108
- pattern: /GRANT\s+(?:ALL|INSERT|UPDATE|DELETE)\s+ON\s+(?:TABLE\s+)?(?:public\.)?(\w+)\s+TO\s+(public|anon)/gi,
109
- message: (match) => `GRANT ${match[0].match(/GRANT\s+(\w+)/)[1]} to ${match[2]} on "${match[1]}" — allows unauthenticated access.`,
110
- fix: (match) => `-- Remove this GRANT or restrict to authenticated:\n-- GRANT SELECT ON ${match[1]} TO authenticated;`
111
- },
112
- {
113
- id: 'DISABLE_RLS',
114
- severity: 'critical',
115
- pattern: /ALTER\s+TABLE\s+(?:public\.)?(\w+)\s+DISABLE\s+ROW\s+LEVEL\s+SECURITY/gi,
116
- message: (match) => `DISABLE ROW LEVEL SECURITY on "${match[1]}" — removes ALL protection.`,
117
- fix: (match) => `-- Do NOT disable RLS. If you need service-level access, use systemDB() in backend code.`
118
- },
119
- {
120
- id: 'POLICY_NO_WITH_CHECK',
121
- severity: 'medium',
122
- pattern: /CREATE\s+POLICY\s+["']?(\w+)["']?\s+ON\s+(?:public\.)?(\w+)\s+FOR\s+(INSERT|UPDATE)\s+TO\s+\w+\s+USING\s*\([^)]+\)(?!\s*WITH\s+CHECK)/gi,
123
- message: (match) => `Policy "${match[1]}" on "${match[2]}" for ${match[3]} has USING but no WITH CHECK — users could write data they can't read.`,
124
- fix: (match) => `-- Add WITH CHECK clause matching the USING clause`
125
- },
126
- {
127
- id: 'USING_TRUE_WRITE',
128
- severity: 'critical',
129
- pattern: /CREATE\s+POLICY\s+["']?(\w+)["']?\s+ON\s+(?:public\.)?(\w+)\s+FOR\s+(INSERT|UPDATE|DELETE|ALL)\s+(?:TO\s+\w+\s+)?USING\s*\(\s*true\s*\)/gi,
130
- message: (match) => `Policy "${match[1]}" on "${match[2]}" allows ${match[3]} with USING(true) — anyone can write.`,
131
- fix: (match) => `-- Replace USING(true) with proper org/user scoping:\n-- USING (organization_id = auth_org_id())`
132
- },
133
- {
134
- id: 'RAW_SERVICE_KEY',
135
- severity: 'critical',
136
- pattern: /eyJ[A-Za-z0-9_-]{20,}\.eyJ[A-Za-z0-9_-]{20,}/g,
137
- message: () => `Hardcoded JWT/service key found in migration file.`,
138
- fix: () => `-- NEVER put keys in migrations. Use environment variables or Vault.`
139
- }
140
- ]
141
-
142
- // ─── Load whitelist from .tetra-quality.json ───────────────────────
143
- function loadWhitelist(projectRoot) {
144
- const configPath = join(projectRoot, '.tetra-quality.json')
145
- if (!existsSync(configPath)) return { securityDefinerWhitelist: [], tables: { noRls: [] } }
146
- try {
147
- const config = JSON.parse(readFileSync(configPath, 'utf-8'))
148
- return {
149
- securityDefinerWhitelist: config?.supabase?.securityDefinerWhitelist || [],
150
- tables: { noRls: config?.supabase?.backendOnlyTables || [] }
151
- }
152
- } catch { return { securityDefinerWhitelist: [], tables: { noRls: [] } } }
153
- }
154
-
155
- // ─── Find migration files ──────────────────────────────────────────
156
- function findMigrations(projectRoot, options) {
157
- if (options.file) {
158
- const filePath = resolve(options.file)
159
- return existsSync(filePath) ? [filePath] : []
160
- }
161
-
162
- if (options.staged) {
163
- try {
164
- const staged = execSync('git diff --cached --name-only --diff-filter=ACM', {
165
- cwd: projectRoot, encoding: 'utf-8'
166
- })
167
- return staged.split('\n')
168
- .filter(f => f.endsWith('.sql'))
169
- .map(f => join(projectRoot, f))
170
- .filter(f => existsSync(f))
171
- } catch { return [] }
172
- }
173
-
174
- // All migrations
175
- const patterns = [
176
- 'supabase/migrations/**/*.sql',
177
- 'backend/supabase/migrations/**/*.sql',
178
- 'migrations/**/*.sql'
179
- ]
180
- const files = []
181
- for (const pattern of patterns) {
182
- files.push(...globSync(pattern, { cwd: projectRoot, absolute: true }))
183
- }
184
- return [...new Set(files)]
185
- }
186
-
187
- // ─── Lint a single file ────────────────────────────────────────────
188
- function lintFile(filePath, projectRoot, whitelist) {
189
- const content = readFileSync(filePath, 'utf-8')
190
- const relPath = relative(projectRoot, filePath)
191
- const findings = []
192
-
193
- for (const rule of RULES) {
194
- // Reset regex lastIndex
195
- rule.pattern.lastIndex = 0
196
- let match
197
- while ((match = rule.pattern.exec(content)) !== null) {
198
- // Check whitelist for DEFINER rules
199
- if (rule.id === 'DEFINER_DATA_RPC' && whitelist.securityDefinerWhitelist.includes(match[1])) {
200
- continue
201
- }
202
- // Check backendOnlyTables for CREATE_TABLE_NO_RLS
203
- if (rule.id === 'CREATE_TABLE_NO_RLS' && whitelist.tables.noRls.includes(match[1])) {
204
- continue
205
- }
206
-
207
- // Run custom test if exists
208
- if (rule.test && !rule.test(match, content)) {
209
- continue
210
- }
211
-
212
- // Find line number
213
- const lineNum = content.substring(0, match.index).split('\n').length
214
-
215
- findings.push({
216
- file: relPath,
217
- line: lineNum,
218
- rule: rule.id,
219
- severity: rule.severity,
220
- message: rule.message(match),
221
- fix: rule.fix ? rule.fix(match) : null
222
- })
223
- }
224
- }
225
-
226
- return findings
227
- }
228
-
229
- // ─── Main ──────────────────────────────────────────────────────────
230
- program
231
- .name('tetra-migration-lint')
232
- .description('Lint SQL migrations for security issues before pushing to Supabase')
233
- .option('--staged', 'Only lint git-staged .sql files (for pre-commit hook)')
234
- .option('--file <path>', 'Lint a specific SQL file')
235
- .option('--fix-suggestions', 'Show fix SQL for each violation')
236
- .option('--json', 'JSON output for CI')
237
- .option('--project <path>', 'Project root (default: cwd)')
238
- .parse()
239
-
240
- const opts = program.opts()
241
- const projectRoot = resolve(opts.project || process.cwd())
242
- const whitelist = loadWhitelist(projectRoot)
243
- const files = findMigrations(projectRoot, opts)
244
-
245
- if (files.length === 0) {
246
- if (!opts.json) console.log(chalk.gray('No migration files to lint.'))
247
- process.exit(0)
248
- }
249
-
250
- const allFindings = []
251
- for (const file of files) {
252
- allFindings.push(...lintFile(file, projectRoot, whitelist))
253
- }
254
-
255
- // ─── Output ────────────────────────────────────────────────────────
256
- if (opts.json) {
257
- console.log(JSON.stringify({
258
- files: files.length,
259
- findings: allFindings,
260
- critical: allFindings.filter(f => f.severity === 'critical').length,
261
- high: allFindings.filter(f => f.severity === 'high').length,
262
- medium: allFindings.filter(f => f.severity === 'medium').length,
263
- passed: allFindings.filter(f => f.severity === 'critical' || f.severity === 'high').length === 0
264
- }, null, 2))
265
- } else {
266
- const critical = allFindings.filter(f => f.severity === 'critical')
267
- const high = allFindings.filter(f => f.severity === 'high')
268
- const medium = allFindings.filter(f => f.severity === 'medium')
269
-
270
- console.log('')
271
- console.log(chalk.bold(' Tetra Migration Lint'))
272
- console.log(chalk.gray(` ${files.length} migration files scanned`))
273
- console.log('')
274
-
275
- if (allFindings.length === 0) {
276
- console.log(chalk.green(' ✅ All migrations pass security lint'))
277
- console.log('')
278
- process.exit(0)
279
- }
280
-
281
- // Group by file
282
- const byFile = {}
283
- for (const f of allFindings) {
284
- if (!byFile[f.file]) byFile[f.file] = []
285
- byFile[f.file].push(f)
286
- }
287
-
288
- for (const [file, findings] of Object.entries(byFile)) {
289
- console.log(chalk.underline(` ${file}`))
290
- for (const f of findings) {
291
- const icon = f.severity === 'critical' ? '🔴' : f.severity === 'high' ? '🟠' : '🟡'
292
- const color = f.severity === 'critical' ? chalk.red : f.severity === 'high' ? chalk.yellow : chalk.gray
293
- console.log(` ${icon} ${color(`[${f.severity.toUpperCase()}]`)} Line ${f.line}: ${f.message}`)
294
- if (opts.fixSuggestions && f.fix) {
295
- console.log(chalk.cyan(` Fix: ${f.fix}`))
296
- }
297
- }
298
- console.log('')
299
- }
300
-
301
- console.log(chalk.bold(' Summary:'))
302
- if (critical.length) console.log(chalk.red(` ${critical.length} CRITICAL`))
303
- if (high.length) console.log(chalk.yellow(` ${high.length} HIGH`))
304
- if (medium.length) console.log(chalk.gray(` ${medium.length} MEDIUM`))
305
- console.log('')
306
-
307
- if (critical.length || high.length) {
308
- console.log(chalk.red.bold(' ❌ BLOCKED — fix CRITICAL/HIGH issues before pushing migrations'))
309
- console.log(chalk.gray(' Run with --fix-suggestions to see fix SQL'))
310
- console.log('')
311
- process.exit(1)
312
- } else {
313
- console.log(chalk.yellow(' ⚠️ Warnings found but not blocking'))
314
- console.log('')
315
- process.exit(0)
316
- }
317
- }
@@ -1,293 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * Tetra Security Gate — AI-powered pre-push security review
5
- *
6
- * Detects security-sensitive file changes in the current git diff,
7
- * submits them to ralph-manager's security gate agent for review,
8
- * and blocks the push if the agent denies the changes.
9
- *
10
- * Usage:
11
- * tetra-security-gate # Auto-detect ralph-manager URL
12
- * tetra-security-gate --url <url> # Explicit ralph-manager URL
13
- * tetra-security-gate --timeout 120 # Custom timeout (seconds)
14
- * tetra-security-gate --dry-run # Show what would be sent, don't block
15
- *
16
- * Exit codes:
17
- * 0 = approved (or no security files changed)
18
- * 1 = denied (security violation found)
19
- * 0 = ralph-manager offline (graceful fallback, doesn't block)
20
- */
21
-
22
- import { program } from 'commander'
23
- import { execSync } from 'child_process'
24
- import chalk from 'chalk'
25
-
26
- // Security-sensitive file patterns
27
- const SECURITY_PATTERNS = [
28
- /supabase\/migrations\/.*\.sql$/i,
29
- /\.rls\./i,
30
- /rls[-_]?policy/i,
31
- /auth[-_]?config/i,
32
- /middleware\/auth/i,
33
- /middleware\/security/i,
34
- /security\.ts$/i,
35
- /security\.js$/i,
36
- /\.env$/,
37
- /\.env\.\w+$/,
38
- /doppler\.yaml$/,
39
- /auth-config/i,
40
- /permissions/i,
41
- /checks\/security\//i,
42
- ]
43
-
44
- function isSecurityFile(file) {
45
- return SECURITY_PATTERNS.some(p => p.test(file))
46
- }
47
-
48
- function getChangedFiles() {
49
- try {
50
- // Files changed between HEAD and upstream (what's being pushed)
51
- const upstream = execSync('git rev-parse --abbrev-ref @{upstream} 2>/dev/null', { encoding: 'utf8' }).trim()
52
- if (upstream) {
53
- return execSync(`git diff --name-only ${upstream}...HEAD`, { encoding: 'utf8' }).trim().split('\n').filter(Boolean)
54
- }
55
- } catch {
56
- // No upstream — compare against origin/main or origin/master
57
- }
58
-
59
- for (const base of ['origin/main', 'origin/master']) {
60
- try {
61
- return execSync(`git diff --name-only ${base}...HEAD`, { encoding: 'utf8' }).trim().split('\n').filter(Boolean)
62
- } catch { /* try next */ }
63
- }
64
-
65
- // Fallback: last commit
66
- try {
67
- return execSync('git diff --name-only HEAD~1', { encoding: 'utf8' }).trim().split('\n').filter(Boolean)
68
- } catch {
69
- return []
70
- }
71
- }
72
-
73
- function getDiff(files) {
74
- try {
75
- const upstream = execSync('git rev-parse --abbrev-ref @{upstream} 2>/dev/null', { encoding: 'utf8' }).trim()
76
- if (upstream) {
77
- return execSync(`git diff ${upstream}...HEAD -- ${files.join(' ')}`, { encoding: 'utf8', maxBuffer: 1024 * 1024 })
78
- }
79
- } catch { /* fallback */ }
80
-
81
- for (const base of ['origin/main', 'origin/master']) {
82
- try {
83
- return execSync(`git diff ${base}...HEAD -- ${files.join(' ')}`, { encoding: 'utf8', maxBuffer: 1024 * 1024 })
84
- } catch { /* try next */ }
85
- }
86
-
87
- try {
88
- return execSync(`git diff HEAD~1 -- ${files.join(' ')}`, { encoding: 'utf8', maxBuffer: 1024 * 1024 })
89
- } catch {
90
- return ''
91
- }
92
- }
93
-
94
- function getProjectName() {
95
- try {
96
- const remoteUrl = execSync('git remote get-url origin 2>/dev/null', { encoding: 'utf8' }).trim()
97
- const match = remoteUrl.match(/\/([^/]+?)(?:\.git)?$/)
98
- if (match) return match[1]
99
- } catch { /* fallback */ }
100
-
101
- try {
102
- return execSync('basename "$(git rev-parse --show-toplevel)"', { encoding: 'utf8' }).trim()
103
- } catch {
104
- return 'unknown'
105
- }
106
- }
107
-
108
- function getRalphManagerUrl() {
109
- // Check .ralph/ports.json first
110
- try {
111
- const portsJson = execSync('cat .ralph/ports.json 2>/dev/null', { encoding: 'utf8' })
112
- const ports = JSON.parse(portsJson)
113
- if (ports.api_url) return ports.api_url
114
- } catch { /* fallback */ }
115
-
116
- // Check RALPH_MANAGER_API env
117
- if (process.env.RALPH_MANAGER_API) return process.env.RALPH_MANAGER_API
118
-
119
- // Default
120
- return 'http://localhost:3005'
121
- }
122
-
123
- async function pollForVerdict(baseUrl, gateId, timeoutSeconds) {
124
- const deadline = Date.now() + timeoutSeconds * 1000
125
- const pollInterval = 3000 // 3 seconds
126
-
127
- while (Date.now() < deadline) {
128
- try {
129
- const resp = await fetch(`${baseUrl}/api/internal/security-gate/${gateId}`)
130
- if (!resp.ok) {
131
- console.error(chalk.yellow(` Poll failed: HTTP ${resp.status}`))
132
- break
133
- }
134
-
135
- const { data } = await resp.json()
136
-
137
- if (data.status === 'approved') {
138
- return { status: 'approved', reason: data.reason, findings: data.findings }
139
- }
140
- if (data.status === 'denied') {
141
- return { status: 'denied', reason: data.reason, findings: data.findings }
142
- }
143
- if (data.status === 'error') {
144
- return { status: 'error', reason: data.reason }
145
- }
146
-
147
- // Still pending — wait and retry
148
- await new Promise(r => setTimeout(r, pollInterval))
149
- } catch {
150
- // Network error — ralph-manager might be restarting
151
- await new Promise(r => setTimeout(r, pollInterval))
152
- }
153
- }
154
-
155
- return { status: 'timeout', reason: `No verdict within ${timeoutSeconds}s` }
156
- }
157
-
158
- program
159
- .name('tetra-security-gate')
160
- .description('AI-powered pre-push security gate — reviews RLS/auth/security changes')
161
- .version('1.0.0')
162
- .option('--url <url>', 'Ralph Manager URL (default: auto-detect)')
163
- .option('--timeout <seconds>', 'Max wait time for agent verdict', '180')
164
- .option('--dry-run', 'Show what would be submitted, do not block')
165
- .action(async (options) => {
166
- try {
167
- console.log(chalk.blue.bold('\n Tetra Security Gate\n'))
168
-
169
- // Step 1: Detect changed files
170
- const allFiles = getChangedFiles()
171
- const securityFiles = allFiles.filter(isSecurityFile)
172
-
173
- if (securityFiles.length === 0) {
174
- console.log(chalk.green(' No security-sensitive files changed — skipping gate.'))
175
- console.log(chalk.gray(` (checked ${allFiles.length} files)\n`))
176
- process.exit(0)
177
- }
178
-
179
- console.log(chalk.yellow(` ${securityFiles.length} security-sensitive file(s) detected:`))
180
- for (const f of securityFiles) {
181
- console.log(chalk.gray(` - ${f}`))
182
- }
183
- console.log()
184
-
185
- // Step 2: Get the diff
186
- const diff = getDiff(securityFiles)
187
- if (!diff.trim()) {
188
- console.log(chalk.green(' No actual diff content — skipping gate.\n'))
189
- process.exit(0)
190
- }
191
-
192
- const project = getProjectName()
193
-
194
- if (options.dryRun) {
195
- console.log(chalk.cyan(' [DRY RUN] Would submit to security gate:'))
196
- console.log(chalk.gray(` Project: ${project}`))
197
- console.log(chalk.gray(` Files: ${securityFiles.length}`))
198
- console.log(chalk.gray(` Diff size: ${diff.length} chars`))
199
- console.log()
200
- process.exit(0)
201
- }
202
-
203
- // Step 3: Submit to ralph-manager
204
- const baseUrl = options.url || getRalphManagerUrl()
205
- const timeout = parseInt(options.timeout, 10)
206
-
207
- console.log(chalk.gray(` Submitting to ${baseUrl}...`))
208
-
209
- let gateId
210
- try {
211
- const resp = await fetch(`${baseUrl}/api/internal/security-gate`, {
212
- method: 'POST',
213
- headers: { 'Content-Type': 'application/json' },
214
- body: JSON.stringify({ project, files_changed: securityFiles, diff }),
215
- signal: AbortSignal.timeout(10_000),
216
- })
217
-
218
- if (!resp.ok) {
219
- const body = await resp.text()
220
- console.error(chalk.yellow(` Ralph Manager returned ${resp.status}: ${body}`))
221
- console.log(chalk.yellow(' Falling back to PASS (ralph-manager error).\n'))
222
- process.exit(0)
223
- }
224
-
225
- const { data } = await resp.json()
226
- gateId = data.id
227
-
228
- // If already resolved (e.g. fallback auto-approve)
229
- if (data.status === 'approved') {
230
- console.log(chalk.green(` ${chalk.bold('APPROVED')} (immediate): ${data.reason || 'OK'}\n`))
231
- process.exit(0)
232
- }
233
- if (data.status === 'denied') {
234
- console.error(chalk.red.bold(`\n PUSH BLOCKED — Security Gate DENIED\n`))
235
- console.error(chalk.red(` Reason: ${data.reason}\n`))
236
- process.exit(1)
237
- }
238
- } catch (err) {
239
- // Ralph-manager offline — don't block the push
240
- console.log(chalk.yellow(` Cannot reach ralph-manager at ${baseUrl}`))
241
- console.log(chalk.yellow(' Falling back to PASS (offline fallback).\n'))
242
- process.exit(0)
243
- }
244
-
245
- // Step 4: Poll for verdict
246
- console.log(chalk.gray(` Agent reviewing... (timeout: ${timeout}s)`))
247
- const result = await pollForVerdict(baseUrl, gateId, timeout)
248
-
249
- if (result.status === 'approved') {
250
- console.log(chalk.green.bold(`\n APPROVED: ${result.reason || 'No issues found'}`))
251
- if (result.findings?.length) {
252
- for (const f of result.findings) {
253
- console.log(chalk.yellow(` ⚠ ${f}`))
254
- }
255
- }
256
- console.log()
257
- process.exit(0)
258
- }
259
-
260
- if (result.status === 'denied') {
261
- console.error(chalk.red.bold(`\n ════════════════════════════════════════════════════════════`))
262
- console.error(chalk.red.bold(` PUSH BLOCKED — Security Gate DENIED`))
263
- console.error(chalk.red.bold(` ════════════════════════════════════════════════════════════`))
264
- console.error(chalk.red(`\n Reason: ${result.reason}`))
265
- if (result.findings?.length) {
266
- console.error(chalk.red(`\n Findings:`))
267
- for (const f of result.findings) {
268
- console.error(chalk.red(` - ${f}`))
269
- }
270
- }
271
- console.error(chalk.yellow(`\n Fix the issues and try again.\n`))
272
- process.exit(1)
273
- }
274
-
275
- if (result.status === 'timeout') {
276
- console.log(chalk.yellow(` Agent did not respond within ${timeout}s.`))
277
- console.log(chalk.yellow(' Falling back to PASS (timeout fallback).\n'))
278
- process.exit(0)
279
- }
280
-
281
- // Unknown status — don't block
282
- console.log(chalk.yellow(` Unexpected verdict status: ${result.status}`))
283
- console.log(chalk.yellow(' Falling back to PASS.\n'))
284
- process.exit(0)
285
-
286
- } catch (err) {
287
- console.error(chalk.red(`\n ERROR: ${err.message}\n`))
288
- // Never block on internal errors
289
- process.exit(0)
290
- }
291
- })
292
-
293
- program.parse()