sneakoscope 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +272 -0
- package/bin/sks.mjs +8 -0
- package/docs/PERFORMANCE.md +39 -0
- package/package.json +46 -0
- package/src/cli/main.mjs +358 -0
- package/src/core/codex-adapter.mjs +49 -0
- package/src/core/db-safety.mjs +347 -0
- package/src/core/decision-contract.mjs +120 -0
- package/src/core/fsx.mjs +328 -0
- package/src/core/hooks-runtime.mjs +110 -0
- package/src/core/hproof.mjs +39 -0
- package/src/core/init.mjs +135 -0
- package/src/core/mission.mjs +56 -0
- package/src/core/no-question-guard.mjs +53 -0
- package/src/core/questions.mjs +99 -0
- package/src/core/retention.mjs +140 -0
- package/src/core/rust-accelerator.mjs +19 -0
- package/src/core/triwiki-attention.mjs +68 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { exists, readJson, writeJsonAtomic, readText, nowIso, appendJsonlBounded } from './fsx.mjs';
|
|
3
|
+
import { missionDir } from './mission.mjs';
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_DB_SAFETY_POLICY = Object.freeze({
|
|
6
|
+
schema_version: 1,
|
|
7
|
+
mode: 'read_only_default',
|
|
8
|
+
destructive_operations: 'deny_always',
|
|
9
|
+
production_writes: 'deny_always',
|
|
10
|
+
mcp_live_writes: 'deny_by_default',
|
|
11
|
+
require_project_scoped_mcp: true,
|
|
12
|
+
require_read_only_mcp_for_real_data: true,
|
|
13
|
+
require_branch_or_local_for_writes: true,
|
|
14
|
+
require_migration_files_for_schema_changes: true,
|
|
15
|
+
require_backup_or_branch_for_any_write: true,
|
|
16
|
+
block_direct_execute_sql_writes: true,
|
|
17
|
+
safe_supabase_mcp_url: 'https://mcp.supabase.com/mcp?project_ref=<project_ref>&read_only=true&features=database,docs',
|
|
18
|
+
max_select_limit_recommendation: 1000,
|
|
19
|
+
always_block_sql_patterns: [
|
|
20
|
+
'drop', 'truncate', 'delete_without_where', 'update_without_where', 'alter_drop', 'create_or_replace',
|
|
21
|
+
'grant', 'revoke', 'disable_rls', 'drop_policy', 'drop_extension', 'drop_schema', 'drop_database'
|
|
22
|
+
],
|
|
23
|
+
always_block_tools: [
|
|
24
|
+
'delete_project', 'pause_project', 'restore_project', 'delete_branch', 'reset_branch', 'merge_branch',
|
|
25
|
+
'supabase db reset', 'supabase db push', 'supabase migration repair'
|
|
26
|
+
]
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export async function ensureDbSafetyPolicy(root) {
|
|
30
|
+
const p = path.join(root, '.sneakoscope', 'db-safety.json');
|
|
31
|
+
if (!(await exists(p))) await writeJsonAtomic(p, DEFAULT_DB_SAFETY_POLICY);
|
|
32
|
+
return p;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function loadDbSafetyPolicy(root) {
|
|
36
|
+
const p = await ensureDbSafetyPolicy(root);
|
|
37
|
+
const data = await readJson(p, {});
|
|
38
|
+
return { ...DEFAULT_DB_SAFETY_POLICY, ...(data || {}) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function safeSupabaseMcpConfig({ projectRef = '<project_ref>', readOnly = true, features = 'database,docs' } = {}) {
|
|
42
|
+
const qs = new URLSearchParams();
|
|
43
|
+
if (projectRef) qs.set('project_ref', projectRef);
|
|
44
|
+
if (readOnly) qs.set('read_only', 'true');
|
|
45
|
+
if (features) qs.set('features', features);
|
|
46
|
+
return {
|
|
47
|
+
mcpServers: {
|
|
48
|
+
supabase: {
|
|
49
|
+
type: 'http',
|
|
50
|
+
url: `https://mcp.supabase.com/mcp?${qs.toString()}`
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function stripSqlComments(sql = '') {
|
|
57
|
+
return String(sql)
|
|
58
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
59
|
+
.replace(/--[^\n\r]*/g, ' ')
|
|
60
|
+
.replace(/\s+/g, ' ')
|
|
61
|
+
.trim();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function norm(s = '') { return stripSqlComments(s).toLowerCase(); }
|
|
65
|
+
|
|
66
|
+
export function splitSqlStatements(sql = '') {
|
|
67
|
+
const text = stripSqlComments(sql);
|
|
68
|
+
const out = [];
|
|
69
|
+
let current = '';
|
|
70
|
+
let quote = null;
|
|
71
|
+
for (let i = 0; i < text.length; i++) {
|
|
72
|
+
const ch = text[i];
|
|
73
|
+
current += ch;
|
|
74
|
+
if ((ch === "'" || ch === '"') && text[i - 1] !== '\\') quote = quote === ch ? null : (quote || ch);
|
|
75
|
+
if (ch === ';' && !quote) { out.push(current.trim()); current = ''; }
|
|
76
|
+
}
|
|
77
|
+
if (current.trim()) out.push(current.trim());
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function hasWhere(stmt) { return /\bwhere\b/i.test(stmt); }
|
|
82
|
+
function hasLimit(stmt) { return /\blimit\s+\d+\b/i.test(stmt); }
|
|
83
|
+
function isReadOnly(stmt) {
|
|
84
|
+
const s = norm(stmt);
|
|
85
|
+
return /^(select|with|show|explain|describe)\b/.test(s) && !/(\binsert\b|\bupdate\b|\bdelete\b|\bdrop\b|\btruncate\b|\balter\b|\bcreate\b|\bgrant\b|\brevoke\b)/.test(s);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function classifySql(sql = '') {
|
|
89
|
+
const statements = splitSqlStatements(sql);
|
|
90
|
+
if (!statements.length) return { level: 'none', kind: 'none', reasons: [], statements: [] };
|
|
91
|
+
const reasons = [];
|
|
92
|
+
let level = 'safe';
|
|
93
|
+
let kind = 'read';
|
|
94
|
+
for (const stmtRaw of statements) {
|
|
95
|
+
const stmt = norm(stmtRaw);
|
|
96
|
+
if (!stmt) continue;
|
|
97
|
+
const destructiveChecks = [
|
|
98
|
+
[/\bdrop\s+database\b/, 'drop_database'],
|
|
99
|
+
[/\bdrop\s+schema\b/, 'drop_schema'],
|
|
100
|
+
[/\bdrop\s+table\b/, 'drop_table'],
|
|
101
|
+
[/\bdrop\s+view\b/, 'drop_view'],
|
|
102
|
+
[/\bdrop\s+materialized\s+view\b/, 'drop_materialized_view'],
|
|
103
|
+
[/\bdrop\s+extension\b/, 'drop_extension'],
|
|
104
|
+
[/\bdrop\s+policy\b/, 'drop_policy'],
|
|
105
|
+
[/\bdrop\b/, 'drop_statement'],
|
|
106
|
+
[/\btruncate\b/, 'truncate'],
|
|
107
|
+
[/\balter\s+table\b[\s\S]*\bdrop\b/, 'alter_table_drop'],
|
|
108
|
+
[/\balter\s+table\b[\s\S]*\brename\b/, 'alter_table_rename'],
|
|
109
|
+
[/\bdelete\s+from\b(?![\s\S]*\bwhere\b)/, 'delete_without_where'],
|
|
110
|
+
[/\bupdate\b[\s\S]*\bset\b(?![\s\S]*\bwhere\b)/, 'update_without_where'],
|
|
111
|
+
[/\bcreate\s+or\s+replace\b/, 'create_or_replace'],
|
|
112
|
+
[/\bdisable\s+row\s+level\s+security\b|\bdisable\s+rls\b/, 'disable_rls'],
|
|
113
|
+
[/\bgrant\b/, 'grant'],
|
|
114
|
+
[/\brevoke\b/, 'revoke']
|
|
115
|
+
];
|
|
116
|
+
for (const [re, reason] of destructiveChecks) {
|
|
117
|
+
if (re.test(stmt)) { reasons.push(reason); level = 'destructive'; kind = 'destructive'; }
|
|
118
|
+
}
|
|
119
|
+
if (/\bdelete\s+from\b/.test(stmt) && hasWhere(stmt) && level !== 'destructive') { reasons.push('delete_with_where'); level = 'write'; kind = 'dml'; }
|
|
120
|
+
if (/\bupdate\b[\s\S]*\bset\b/.test(stmt) && hasWhere(stmt) && level !== 'destructive') { reasons.push('update_with_where'); level = 'write'; kind = 'dml'; }
|
|
121
|
+
if (/\binsert\s+into\b|\bupsert\b/.test(stmt) && level !== 'destructive') { reasons.push('insert_or_upsert'); level = 'write'; kind = 'dml'; }
|
|
122
|
+
if (/\bcreate\s+(table|index|schema|view|function|policy|extension)\b|\balter\s+table\b/.test(stmt) && level !== 'destructive') { reasons.push('schema_change'); level = 'write'; kind = 'ddl'; }
|
|
123
|
+
if (/\bcopy\b[\s\S]*\bfrom\b/.test(stmt) && level !== 'destructive') { reasons.push('bulk_copy_from'); level = 'write'; kind = 'bulk'; }
|
|
124
|
+
if (!isReadOnly(stmtRaw) && level === 'safe') { reasons.push('non_readonly_or_unknown_sql'); level = 'write'; kind = 'unknown_write'; }
|
|
125
|
+
if (isReadOnly(stmtRaw) && !hasLimit(stmtRaw) && /^\s*select\s+\*/i.test(stmtRaw)) reasons.push('select_star_without_limit');
|
|
126
|
+
}
|
|
127
|
+
return { level, kind, reasons: [...new Set(reasons)], statements };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function classifyCommand(command = '') {
|
|
131
|
+
const c = String(command);
|
|
132
|
+
const low = c.toLowerCase();
|
|
133
|
+
const reasons = [];
|
|
134
|
+
if (!low.trim()) return { level: 'none', kind: 'none', reasons: [], command: c };
|
|
135
|
+
const hard = [
|
|
136
|
+
[/\bsupabase\s+db\s+reset\b/, 'supabase_db_reset'],
|
|
137
|
+
[/\bsupabase\s+db\s+push\b/, 'supabase_db_push'],
|
|
138
|
+
[/\bsupabase\s+migration\s+repair\b/, 'supabase_migration_repair'],
|
|
139
|
+
[/\bprisma\s+migrate\s+reset\b/, 'prisma_migrate_reset'],
|
|
140
|
+
[/\bprisma\s+db\s+push\b/, 'prisma_db_push'],
|
|
141
|
+
[/\bdrizzle-kit\s+push\b/, 'drizzle_push'],
|
|
142
|
+
[/\bsequelize\s+db:migrate:undo/, 'sequelize_migrate_undo'],
|
|
143
|
+
[/\bknex\s+migrate:rollback\b/, 'knex_migrate_rollback'],
|
|
144
|
+
[/\b(dropdb|createdb)\b/, 'postgres_database_admin_command']
|
|
145
|
+
];
|
|
146
|
+
for (const [re, reason] of hard) if (re.test(low)) reasons.push(reason);
|
|
147
|
+
const maybeSql = extractSqlLiterals(c).join('\n');
|
|
148
|
+
const sqlClass = maybeSql ? classifySql(maybeSql) : { level: 'none', reasons: [] };
|
|
149
|
+
if (reasons.length) return { level: 'destructive', kind: 'db_command', reasons, sql: sqlClass, command: c };
|
|
150
|
+
if (/\b(psql|supabase|prisma|drizzle-kit|knex|sequelize)\b/.test(low)) {
|
|
151
|
+
if (sqlClass.level === 'destructive' || sqlClass.level === 'write') return { level: sqlClass.level, kind: 'db_command', reasons: sqlClass.reasons, sql: sqlClass, command: c };
|
|
152
|
+
return { level: sqlClass.level === 'safe' ? 'safe' : 'possible_db', kind: 'db_command', reasons: sqlClass.reasons, sql: sqlClass, command: c };
|
|
153
|
+
}
|
|
154
|
+
return { level: sqlClass.level, kind: sqlClass.kind, reasons: sqlClass.reasons, sql: sqlClass, command: c };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function extractSqlLiterals(command = '') {
|
|
158
|
+
const out = [];
|
|
159
|
+
const patterns = [
|
|
160
|
+
/-c\s+(['"])([\s\S]*?)\1/g,
|
|
161
|
+
/--command\s+(['"])([\s\S]*?)\1/g,
|
|
162
|
+
/--sql\s+(['"])([\s\S]*?)\1/g
|
|
163
|
+
];
|
|
164
|
+
for (const re of patterns) {
|
|
165
|
+
let m;
|
|
166
|
+
while ((m = re.exec(command))) out.push(m[2]);
|
|
167
|
+
}
|
|
168
|
+
if (/\b(select|insert|update|delete|drop|truncate|alter|create|grant|revoke)\b/i.test(command)) out.push(command);
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function recursivelyCollectStrings(obj, out = [], depth = 0) {
|
|
173
|
+
if (depth > 8 || obj == null) return out;
|
|
174
|
+
if (typeof obj === 'string') { out.push(obj); return out; }
|
|
175
|
+
if (Array.isArray(obj)) { for (const x of obj) recursivelyCollectStrings(x, out, depth + 1); return out; }
|
|
176
|
+
if (typeof obj === 'object') {
|
|
177
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
178
|
+
if (/^(sql|query|statement|command|migration|body|input|text)$/i.test(k) || typeof v === 'object') recursivelyCollectStrings(v, out, depth + 1);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function classifyToolPayload(payload = {}) {
|
|
185
|
+
const strings = recursivelyCollectStrings(payload).slice(0, 200);
|
|
186
|
+
const toolName = [payload.tool_name, payload.name, payload.tool?.name, payload.server, payload.mcp_tool, payload.tool, payload.type].filter(Boolean).join(' ').toLowerCase();
|
|
187
|
+
const combined = strings.join('\n');
|
|
188
|
+
const sqlClass = classifySql(combined);
|
|
189
|
+
const commandClass = classifyCommand(strings.find((s) => /\b(supabase|psql|prisma|drizzle|knex|sequelize)\b/i.test(s)) || '');
|
|
190
|
+
const toolReasons = [];
|
|
191
|
+
if (/supabase|postgres|database|execute_sql|apply_migration|mcp/.test(toolName)) toolReasons.push('database_tool');
|
|
192
|
+
if (/delete_project|pause_project|restore_project|delete_branch|reset_branch|merge_branch/.test(toolName)) toolReasons.push('dangerous_supabase_management_tool');
|
|
193
|
+
let level = 'none';
|
|
194
|
+
for (const candidate of [sqlClass.level, commandClass.level]) {
|
|
195
|
+
if (candidate === 'destructive') level = 'destructive';
|
|
196
|
+
else if (candidate === 'write' && level !== 'destructive') level = 'write';
|
|
197
|
+
else if ((candidate === 'safe' || candidate === 'possible_db') && level === 'none') level = candidate;
|
|
198
|
+
}
|
|
199
|
+
if (toolReasons.includes('dangerous_supabase_management_tool')) level = 'destructive';
|
|
200
|
+
if (toolReasons.includes('database_tool') && level === 'none') level = 'possible_db';
|
|
201
|
+
return { level, toolName, toolReasons, sql: sqlClass, command: commandClass, stringsExamined: strings.length };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function contractAllowsDbWrite(contract = {}) {
|
|
205
|
+
const hc = contract.hard_constraints || {};
|
|
206
|
+
const mode = hc.database_write_mode || hc.db_write_mode || contract.answers?.DATABASE_WRITE_MODE || 'read_only_only';
|
|
207
|
+
const env = hc.database_target_environment || contract.answers?.DATABASE_TARGET_ENVIRONMENT || 'no_database';
|
|
208
|
+
const destructive = hc.destructive_db_operations_allowed === true || contract.answers?.DESTRUCTIVE_DB_OPERATIONS_ALLOWED === 'yes';
|
|
209
|
+
const migrationApply = contract.answers?.DB_MIGRATION_APPLY_ALLOWED || 'no';
|
|
210
|
+
return { mode, env, destructive, migrationApply };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function evaluateDbSafety({ classification, policy = DEFAULT_DB_SAFETY_POLICY, contract = null, duringRalph = false } = {}) {
|
|
214
|
+
const cls = classification || { level: 'none', reasons: [] };
|
|
215
|
+
const reasons = [];
|
|
216
|
+
const effective = contractAllowsDbWrite(contract || {});
|
|
217
|
+
if (cls.level === 'none') return { allowed: true, action: 'allow', reasons: [], classification: cls };
|
|
218
|
+
if (cls.level === 'safe') return { allowed: true, action: 'allow', reasons: ['read_only_operation'], classification: cls };
|
|
219
|
+
if (cls.level === 'possible_db') return { allowed: !duringRalph, action: duringRalph ? 'block' : 'warn', reasons: duringRalph ? ['unknown_database_operation_blocked_during_ralph'] : ['unknown_database_operation'], classification: cls };
|
|
220
|
+
if (cls.level === 'destructive') reasons.push('destructive_database_operation_blocked_always');
|
|
221
|
+
if (cls.level === 'write') {
|
|
222
|
+
if (effective.mode === 'read_only_only') reasons.push('database_write_mode_is_read_only_only');
|
|
223
|
+
if (effective.env === 'production' || effective.env === 'production_read_only') reasons.push('production_database_writes_forbidden');
|
|
224
|
+
if (!['local_dev', 'preview_branch', 'supabase_branch'].includes(effective.env)) reasons.push('database_write_target_not_local_or_branch');
|
|
225
|
+
if (policy.block_direct_execute_sql_writes && cls.toolReasons?.includes?.('database_tool')) reasons.push('direct_mcp_execute_sql_writes_blocked');
|
|
226
|
+
}
|
|
227
|
+
if (effective.destructive) reasons.push('contract_attempted_to_allow_destructive_but_policy_denies');
|
|
228
|
+
if (reasons.length) return { allowed: false, action: 'block', reasons, classification: cls, effective };
|
|
229
|
+
return { allowed: true, action: 'allow', reasons: ['write_allowed_by_contract_to_safe_target'], classification: cls, effective };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function loadMissionContract(root, state = {}) {
|
|
233
|
+
if (!state?.mission_id) return null;
|
|
234
|
+
const p = path.join(missionDir(root, state.mission_id), 'decision-contract.json');
|
|
235
|
+
if (!(await exists(p))) return null;
|
|
236
|
+
return readJson(p, null);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function checkDbOperation(root, state, payload, { duringRalph = false } = {}) {
|
|
240
|
+
const policy = await loadDbSafetyPolicy(root);
|
|
241
|
+
const contract = await loadMissionContract(root, state);
|
|
242
|
+
const classification = classifyToolPayload(payload);
|
|
243
|
+
const decision = evaluateDbSafety({ classification, policy, contract, duringRalph });
|
|
244
|
+
if (decision.action !== 'allow' && state?.mission_id) {
|
|
245
|
+
await appendJsonlBounded(path.join(missionDir(root, state.mission_id), 'db-safety.jsonl'), { ts: nowIso(), decision });
|
|
246
|
+
}
|
|
247
|
+
return decision;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function checkSqlFile(file) {
|
|
251
|
+
const sql = await readText(file);
|
|
252
|
+
return classifySql(sql);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function dbBlockReason(decision) {
|
|
256
|
+
return [
|
|
257
|
+
'Sneakoscope Codex Database Safety Gate blocked this operation.',
|
|
258
|
+
`Reasons: ${(decision.reasons || []).join(', ') || 'unknown'}.`,
|
|
259
|
+
'Destructive database operations are never allowed. Production writes are forbidden. Supabase/Postgres MCP write tools must not be used for live destructive changes.',
|
|
260
|
+
'Use read-only/project-scoped Supabase MCP URLs, create migration files, and apply them only to local or preview/branch environments when explicitly allowed by the sealed contract.'
|
|
261
|
+
].join(' ');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function scanDbSafety(root, opts = {}) {
|
|
265
|
+
const findings = [];
|
|
266
|
+
findings.push(...await scanSupabaseMcpConfigs(root));
|
|
267
|
+
if (opts.includeMigrations) findings.push(...await scanMissionMigrationFiles(root, opts));
|
|
268
|
+
const ok = !findings.some((f) => ['critical', 'high'].includes(f.severity));
|
|
269
|
+
const report = { checked_at: nowIso(), ok, findings };
|
|
270
|
+
await writeJsonAtomic(path.join(root, '.sneakoscope', 'db-safety-scan.json'), report).catch(() => {});
|
|
271
|
+
return report;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function scanSupabaseMcpConfigs(root) {
|
|
275
|
+
const files = ['.codex/config.toml', '.cursor/mcp.json', '.windsurf/mcp_config.json', '.vscode/mcp.json', '.mcp.json', 'mcp.json', 'claude_desktop_config.json', '.claude/mcp.json'];
|
|
276
|
+
const findings = [];
|
|
277
|
+
for (const name of files) {
|
|
278
|
+
const file = path.join(root, name);
|
|
279
|
+
if (!(await exists(file))) continue;
|
|
280
|
+
const text = await readText(file, '');
|
|
281
|
+
if (!/supabase|mcp\.supabase\.com/i.test(text)) continue;
|
|
282
|
+
const urls = extractSupabaseMcpUrls(text);
|
|
283
|
+
if (!urls.length) {
|
|
284
|
+
findings.push({ id: 'supabase_mcp_unparsed', severity: 'medium', file: rel(root, file), reason: 'Supabase MCP reference found but URL could not be parsed. Verify read_only=true, project_ref, and restricted features manually.' });
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
for (const url of urls) findings.push(...checkSupabaseMcpUrl(url).map((x) => ({ ...x, file: rel(root, file), url: redactUrl(url) })));
|
|
288
|
+
}
|
|
289
|
+
return findings;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function extractSupabaseMcpUrls(text) {
|
|
293
|
+
const out = new Set();
|
|
294
|
+
const re = /https:\/\/mcp\.supabase\.com\/mcp[^"'\s)>,]*/gi;
|
|
295
|
+
let m;
|
|
296
|
+
while ((m = re.exec(text))) out.add(m[0]);
|
|
297
|
+
if (/mcp\.supabase\.com\/mcp/i.test(text) && !out.size) out.add('https://mcp.supabase.com/mcp');
|
|
298
|
+
return [...out];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function checkSupabaseMcpUrl(url) {
|
|
302
|
+
const findings = [];
|
|
303
|
+
let u;
|
|
304
|
+
try { u = new URL(url); } catch { u = new URL('https://mcp.supabase.com/mcp'); }
|
|
305
|
+
const q = u.searchParams;
|
|
306
|
+
if (q.get('read_only') !== 'true') findings.push({ id: 'supabase_mcp_not_read_only', severity: 'critical', reason: 'Supabase MCP must use read_only=true.' });
|
|
307
|
+
if (!q.get('project_ref')) findings.push({ id: 'supabase_mcp_not_project_scoped', severity: 'critical', reason: 'Supabase MCP must include project_ref=<id>.' });
|
|
308
|
+
const featuresRaw = q.get('features');
|
|
309
|
+
if (!featuresRaw) findings.push({ id: 'supabase_mcp_features_unrestricted', severity: 'high', reason: 'Supabase MCP must restrict features, e.g. features=database,docs.' });
|
|
310
|
+
else {
|
|
311
|
+
const allowed = new Set(['database', 'docs', 'development']);
|
|
312
|
+
const forbidden = new Set(['account', 'account_management', 'branching', 'storage', 'edge_functions', 'edge-functions']);
|
|
313
|
+
for (const f of featuresRaw.split(',').map((x) => x.trim().toLowerCase()).filter(Boolean)) {
|
|
314
|
+
if (forbidden.has(f)) findings.push({ id: 'supabase_mcp_forbidden_feature', severity: 'critical', feature: f, reason: `Supabase MCP feature '${f}' is forbidden.` });
|
|
315
|
+
else if (!allowed.has(f)) findings.push({ id: 'supabase_mcp_unapproved_feature', severity: 'high', feature: f, reason: `Supabase MCP feature '${f}' is not in Sneakoscope Codex allowlist.` });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return findings;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function scanMissionMigrationFiles(root, opts = {}) {
|
|
322
|
+
const since = opts.since ? Date.parse(opts.since) : 0;
|
|
323
|
+
const findings = [];
|
|
324
|
+
async function walk(dir, depth = 0) {
|
|
325
|
+
if (depth > 8) return;
|
|
326
|
+
let entries = [];
|
|
327
|
+
try { entries = await (await import('node:fs/promises')).readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
328
|
+
for (const e of entries) {
|
|
329
|
+
const p = path.join(dir, e.name);
|
|
330
|
+
const rp = rel(root, p);
|
|
331
|
+
if (rp.startsWith('.git/') || rp.startsWith('node_modules/') || rp.startsWith('.sneakoscope/')) continue;
|
|
332
|
+
if (e.isDirectory()) await walk(p, depth + 1);
|
|
333
|
+
else if (e.isFile() && /(^|\/)(supabase\/migrations|migrations|db\/migrations|database\/migrations)\/.*\.sql$/i.test(rp)) {
|
|
334
|
+
let st; try { st = await (await import('node:fs/promises')).stat(p); } catch { continue; }
|
|
335
|
+
if (since && st.mtimeMs < since - 5000) continue;
|
|
336
|
+
const cls = classifySql(await readText(p, ''));
|
|
337
|
+
if (cls.level === 'destructive') findings.push({ id: 'destructive_migration_file', severity: 'critical', file: rp, classification: cls, reason: 'Mission migration file contains destructive SQL.' });
|
|
338
|
+
else if (cls.level === 'write') findings.push({ id: 'write_migration_file', severity: 'info', file: rp, classification: cls, reason: 'Migration file contains write/DDL SQL; review artifact only.' });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
await walk(root, 0);
|
|
343
|
+
return findings;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function redactUrl(url) { return String(url).replace(/(access_token|token|apikey|key)=([^&]+)/gi, '$1=<redacted>'); }
|
|
347
|
+
function rel(root, file) { return path.relative(root, file).split(path.sep).join('/'); }
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readJson, writeJsonAtomic, nowIso, sha256 } from './fsx.mjs';
|
|
3
|
+
|
|
4
|
+
function isEmptyAnswer(v) {
|
|
5
|
+
if (v === undefined || v === null) return true;
|
|
6
|
+
if (typeof v === 'string' && v.trim() === '') return true;
|
|
7
|
+
if (Array.isArray(v) && v.length === 0) return true;
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function validateAnswers(schema, answers) {
|
|
12
|
+
const errors = [];
|
|
13
|
+
const resolved = [];
|
|
14
|
+
for (const slot of schema.slots) {
|
|
15
|
+
const value = answers[slot.id];
|
|
16
|
+
if (slot.required && isEmptyAnswer(value)) {
|
|
17
|
+
errors.push({ slot: slot.id, error: 'required_answer_missing' });
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (!isEmptyAnswer(value) && slot.options) {
|
|
21
|
+
const values = Array.isArray(value) ? value : [value];
|
|
22
|
+
for (const val of values) {
|
|
23
|
+
if (!slot.options.includes(val)) errors.push({ slot: slot.id, error: 'invalid_option', value: val, allowed: slot.options });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (!isEmptyAnswer(value)) resolved.push(slot.id);
|
|
27
|
+
}
|
|
28
|
+
if (answers.DESTRUCTIVE_DB_OPERATIONS_ALLOWED && answers.DESTRUCTIVE_DB_OPERATIONS_ALLOWED !== 'never') {
|
|
29
|
+
errors.push({ slot: 'DESTRUCTIVE_DB_OPERATIONS_ALLOWED', error: 'sneakoscope_never_allows_destructive_database_operations' });
|
|
30
|
+
}
|
|
31
|
+
if (answers.DATABASE_TARGET_ENVIRONMENT === 'production_write') {
|
|
32
|
+
errors.push({ slot: 'DATABASE_TARGET_ENVIRONMENT', error: 'production_write_target_forbidden' });
|
|
33
|
+
}
|
|
34
|
+
return { ok: errors.length === 0, errors, resolved, totalRequired: schema.slots.filter((s) => s.required).length };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function buildDecisionContract({ mission, schema, answers }) {
|
|
38
|
+
const defaults = {
|
|
39
|
+
if_multiple_valid_implementations: 'choose_smallest_reversible_change',
|
|
40
|
+
if_test_command_unknown: 'infer_from_repo_scripts_and_run_most_local_relevant_test',
|
|
41
|
+
if_e2e_unavailable: 'run_unit_or_integration_and_record_e2e_not_executed',
|
|
42
|
+
if_dependency_needed: 'avoid_new_dependency_unless_allowed_by_contract',
|
|
43
|
+
if_existing_behavior_conflict: 'preserve_existing_public_behavior',
|
|
44
|
+
if_visual_cartridge_conflict: 'vgraph_json_wins_over_sheet_png',
|
|
45
|
+
if_wiki_conflict: 'current_code_wins_over_wiki',
|
|
46
|
+
if_low_confidence_claim: 'read_source_do_not_ask_user',
|
|
47
|
+
if_unresolvable_optional_scope: 'defer_optional_subtask_and_complete_core_acceptance_criteria',
|
|
48
|
+
if_unresolvable_required_scope: 'choose_safest_minimal_implementation_within_hard_constraints',
|
|
49
|
+
if_database_uncertain: 'read_only_only_or_skip_database_action',
|
|
50
|
+
if_database_write_needed: 'create_migration_file_only_unless_contract_allows_local_or_branch_apply',
|
|
51
|
+
if_mcp_database_tool_needed: 'use_read_only_project_scoped_supabase_mcp_or_do_not_use_mcp',
|
|
52
|
+
if_database_blast_radius_unknown: 'do_not_execute_live_dml'
|
|
53
|
+
};
|
|
54
|
+
const fallback = answers.MID_RALPH_UNKNOWN_POLICY || ['preserve_existing_behavior', 'smallest_reversible_change', 'defer_optional_scope', 'block_only_if_no_safe_path'];
|
|
55
|
+
const contract = {
|
|
56
|
+
schema_version: 2,
|
|
57
|
+
mission_id: mission.id,
|
|
58
|
+
status: 'sealed',
|
|
59
|
+
sealed_at: nowIso(),
|
|
60
|
+
ralph_mode: 'no_questions',
|
|
61
|
+
prompt: mission.prompt,
|
|
62
|
+
answers,
|
|
63
|
+
hard_constraints: {
|
|
64
|
+
ask_user_during_ralph: false,
|
|
65
|
+
public_api_change_allowed: answers.PUBLIC_API_CHANGE_ALLOWED || 'no',
|
|
66
|
+
db_schema_change_allowed: answers.DB_SCHEMA_CHANGE_ALLOWED || 'no',
|
|
67
|
+
dependency_change_allowed: answers.DEPENDENCY_CHANGE_ALLOWED || 'no',
|
|
68
|
+
auth_protocol_change_allowed: answers.AUTH_PROTOCOL_CHANGE_ALLOWED || 'no',
|
|
69
|
+
destructive_db_operations_allowed: false,
|
|
70
|
+
database_target_environment: answers.DATABASE_TARGET_ENVIRONMENT || 'no_database',
|
|
71
|
+
database_write_mode: answers.DATABASE_WRITE_MODE || 'read_only_only',
|
|
72
|
+
supabase_mcp_policy: answers.SUPABASE_MCP_POLICY || 'not_used',
|
|
73
|
+
db_backup_or_branch_required: answers.DB_BACKUP_OR_BRANCH_REQUIRED || 'yes_for_any_write',
|
|
74
|
+
db_max_blast_radius: answers.DB_MAX_BLAST_RADIUS || 'no_live_dml',
|
|
75
|
+
production_database_writes_allowed: false,
|
|
76
|
+
mcp_direct_execute_sql_writes_allowed: false,
|
|
77
|
+
db_reset_allowed: false,
|
|
78
|
+
db_drop_allowed: false,
|
|
79
|
+
db_truncate_allowed: false,
|
|
80
|
+
db_mass_delete_update_allowed: false
|
|
81
|
+
},
|
|
82
|
+
database_safety: {
|
|
83
|
+
policy: 'destructive_denied_always',
|
|
84
|
+
supabase_mcp_recommended_url: 'https://mcp.supabase.com/mcp?project_ref=<project_ref>&read_only=true&features=database,docs',
|
|
85
|
+
allowed_targets_for_write: ['local_dev', 'preview_branch', 'supabase_branch'],
|
|
86
|
+
forbidden_operations: ['DROP', 'TRUNCATE', 'DELETE_WITHOUT_WHERE', 'UPDATE_WITHOUT_WHERE', 'DB_RESET', 'DB_PUSH', 'PROJECT_DELETE', 'BRANCH_RESET_OR_MERGE_OR_DELETE', 'DISABLE_RLS', 'BROAD_GRANT_REVOKE'],
|
|
87
|
+
migration_apply_allowed: answers.DB_MIGRATION_APPLY_ALLOWED || 'no',
|
|
88
|
+
read_only_query_limit: answers.DB_READ_ONLY_QUERY_LIMIT || '1000'
|
|
89
|
+
},
|
|
90
|
+
acceptance_criteria: Array.isArray(answers.ACCEPTANCE_CRITERIA) ? answers.ACCEPTANCE_CRITERIA : String(answers.ACCEPTANCE_CRITERIA || '').split('\n').map((x) => x.trim()).filter(Boolean),
|
|
91
|
+
non_goals: Array.isArray(answers.NON_GOALS) ? answers.NON_GOALS : String(answers.NON_GOALS || '').split('\n').map((x) => x.trim()).filter(Boolean),
|
|
92
|
+
test_scope: answers.TEST_SCOPE,
|
|
93
|
+
approved_defaults: defaults,
|
|
94
|
+
decision_ladder: [
|
|
95
|
+
'seed_contract',
|
|
96
|
+
'explicit_user_answer',
|
|
97
|
+
'approved_defaults',
|
|
98
|
+
'database_safety_policy',
|
|
99
|
+
'AGENTS.md',
|
|
100
|
+
'vgraph.json',
|
|
101
|
+
'current_code_and_tests',
|
|
102
|
+
...fallback,
|
|
103
|
+
'blocked_report_only_if_no_safe_path'
|
|
104
|
+
],
|
|
105
|
+
implementation_allowed: true
|
|
106
|
+
};
|
|
107
|
+
contract.sealed_hash = sha256(JSON.stringify(contract));
|
|
108
|
+
return contract;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function sealContract(missionDir, mission) {
|
|
112
|
+
const schema = await readJson(path.join(missionDir, 'required-answers.schema.json'));
|
|
113
|
+
const answers = await readJson(path.join(missionDir, 'answers.json'));
|
|
114
|
+
const validation = validateAnswers(schema, answers);
|
|
115
|
+
if (!validation.ok) return { ok: false, validation };
|
|
116
|
+
const contract = buildDecisionContract({ mission, schema, answers });
|
|
117
|
+
await writeJsonAtomic(path.join(missionDir, 'decision-contract.json'), contract);
|
|
118
|
+
await writeJsonAtomic(path.join(missionDir, 'answer-validation.json'), validation);
|
|
119
|
+
return { ok: true, validation, contract };
|
|
120
|
+
}
|