sneakoscope 0.7.53 → 0.7.55

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/README.md CHANGED
@@ -211,7 +211,7 @@ sks --mad
211
211
  sks --mad --yes
212
212
  ```
213
213
 
214
- This syncs existing codex-lb/Codex CLI auth before launch, creates/uses the `sks-mad-high` Codex profile for a one-shot full-access, high-reasoning tmux session with `sandbox_mode = "danger-full-access"` and `approval_policy = "never"`, opens an active MAD-SKS permission gate for that tmux run, then launches Codex with `--sandbox danger-full-access --ask-for-approval never` and attaches to the session in an interactive terminal. If codex-lb is configured and no explicit `--workspace`/`--session` was passed, SKS opens a fresh tmux session so the repaired key is loaded by the Codex process immediately. While the gate is active, live server work, Supabase MCP database writes, direct SQL, targeted DML, schema cleanup, and needed migrations are allowed. Catastrophic database wipe/all-row/project-management safeguards remain active. Repeat launches reuse the same named SKS MAD tmux session unless auth repair requires a fresh codex-lb session.
214
+ This syncs existing codex-lb/Codex CLI auth before launch, creates/uses the `sks-mad-high` Codex profile for a one-shot full-access, high-reasoning tmux session with `sandbox_mode = "danger-full-access"` and `approval_policy = "never"`, opens an active MAD-SKS permission gate for that tmux run, then launches Codex with `--sandbox danger-full-access --ask-for-approval never` and attaches to the session in an interactive terminal. If codex-lb is configured and no explicit `--workspace`/`--session` was passed, SKS opens a fresh tmux session so the repaired key is loaded by the Codex process immediately. While the gate is active, live server work, Supabase MCP database writes, direct SQL, targeted DML, schema cleanup, Supabase MCP `apply_migration`, and required Supabase CLI migration application such as `supabase migration up` or `supabase db push` are allowed. Catastrophic database wipe/all-row/project-management safeguards remain active. Repeat launches reuse the same named SKS MAD tmux session unless auth repair requires a fresh codex-lb session.
215
215
 
216
216
  MAD does not disable the pipeline contract: stages, executors, reviewers, and auto-review policy still must not invent unrequested fallback implementation code. If the requested path cannot be implemented, SKS should block with evidence rather than add substitute behavior.
217
217
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sneakoscope",
3
3
  "displayName": "ㅅㅋㅅ",
4
- "version": "0.7.53",
4
+ "version": "0.7.55",
5
5
  "description": "Sneakoscope Codex: database-safe Codex CLI/App harness with Team, Goal, AutoResearch, TriWiki, and Honest Mode.",
6
6
  "type": "module",
7
7
  "homepage": "https://github.com/mandarange/Sneakoscope-Codex#readme",
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { codexRemoteControlStatus, formatCodexRemoteControlStatus } from '../core/codex-app.mjs';
3
+ import { forceGpt55CodexConfigArgs } from '../core/codex-model-guard.mjs';
3
4
 
4
5
  export async function codexAppRemoteControlCommand(args = [], opts = {}) {
5
6
  const controlArgs = argsBeforeSeparator(args);
@@ -27,7 +28,7 @@ export async function codexAppRemoteControlCommand(args = [], opts = {}) {
27
28
  return;
28
29
  }
29
30
 
30
- const passthrough = stripSeparator(args);
31
+ const passthrough = forceGpt55CodexConfigArgs(stripSeparator(args));
31
32
  const spawnFn = opts.spawn || spawn;
32
33
  const code = await spawnInherited(spawnFn, status.codex_cli.bin, ['remote-control', ...passthrough], {
33
34
  cwd: process.cwd(),
package/src/cli/main.mjs CHANGED
@@ -5,7 +5,7 @@ import readline from 'node:readline/promises';
5
5
  import { stdin as input, stdout as output } from 'node:process';
6
6
  import { projectRoot, readJson, writeJsonAtomic, writeTextAtomic, appendJsonlBounded, nowIso, exists, ensureDir, tmpdir, packageRoot, dirSize, formatBytes, which, runProcess, PACKAGE_VERSION, sksRoot, globalSksRoot, findProjectRoot, readStdin } from '../core/fsx.mjs';
7
7
  import { initProject, installSkills, normalizeInstallScope, sksCommandPrefix } from '../core/init.mjs';
8
- import { getCodexInfo, runCodexExec } from '../core/codex-adapter.mjs';
8
+ import { buildCodexExecArgs, getCodexInfo, runCodexExec } from '../core/codex-adapter.mjs';
9
9
  import { createMission, loadMission, findLatestMission, missionDir, setCurrent, stateFile } from '../core/mission.mjs';
10
10
  import { buildQuestionSchema, writeQuestions } from '../core/questions.mjs';
11
11
  import { sealContract, validateAnswers } from '../core/decision-contract.mjs';
@@ -2277,6 +2277,10 @@ async function selftest() {
2277
2277
  if (defaultFastHighPlan.codexArgs.join(' ') !== '--model gpt-5.5 -c model_reasoning_effort="high"') throw new Error('selftest failed: default sks tmux launch is not fast-high');
2278
2278
  const forcedModelPlan = await buildTmuxLaunchPlan({ root: tmp, env: { SKS_CODEX_MODEL: 'gpt-5.4-mini', SKS_CODEX_FAST_HIGH: '0', SKS_CODEX_REASONING: 'medium' }, tmux: { ok: true, bin: 'tmux', version: '3.4' }, codex: { bin: 'codex', version: 'codex-cli 99.0.0' }, app: { ok: true } });
2279
2279
  if (forcedModelPlan.codexArgs.includes('gpt-5.4-mini') || forcedModelPlan.codexArgs.join(' ') !== '--model gpt-5.5 -c model_reasoning_effort="medium"') throw new Error('selftest failed: sks tmux launch allowed a non-GPT-5.5 model override');
2280
+ const explicitBadModelPlan = await buildTmuxLaunchPlan({ root: tmp, codexArgs: ['--profile', 'legacy-5.4', '--model', 'gpt-5.4-mini', '-c', 'model="gpt-5.4"', '-c', 'model_reasoning_effort="low"'], tmux: { ok: true, bin: 'tmux', version: '3.4' }, codex: { bin: 'codex', version: 'codex-cli 99.0.0' }, app: { ok: true } });
2281
+ if (explicitBadModelPlan.codexArgs.join(' ').includes('gpt-5.4') || explicitBadModelPlan.codexArgs.join(' ') !== '--model gpt-5.5 --profile legacy-5.4 -c model_reasoning_effort="low"') throw new Error('selftest failed: explicit tmux model override was not forced back to GPT-5.5');
2282
+ const codexExecArgs = buildCodexExecArgs({ root: tmp, prompt: 'model guard selftest', profile: 'legacy-5.4', extraArgs: ['--model=gpt-5.4-mini', '--config', 'model = "gpt-5.4"', '-c', 'model_reasoning_effort="medium"'] });
2283
+ if (codexExecArgs.join(' ').includes('gpt-5.4') || !codexExecArgs.includes('gpt-5.5') || codexExecArgs.includes('--model=gpt-5.4-mini')) throw new Error('selftest failed: codex exec args allowed a non-GPT-5.5 model override');
2280
2284
  const codexLbHome = path.join(tmp, 'codex-lb-home');
2281
2285
  await ensureDir(path.join(codexLbHome, '.codex'));
2282
2286
  const codexLbFakeBin = path.join(tmp, 'codex-lb-fake-bin');
@@ -2309,6 +2313,7 @@ async function selftest() {
2309
2313
  if (!/^model = "gpt-5\.5"/m.test(codexLbConfig) || !codexLbConfig.includes('service_tier = "fast"') || !codexLbConfig.includes('hooks = true') || codexLbConfig.includes('codex_hooks = true') || !codexLbConfig.includes('fast_mode = true') || !codexLbConfig.includes('fast_mode_ui = true') || !codexLbConfig.includes('[user.fast_mode]') || !codexLbConfig.includes('visible = true') || !codexLbConfig.includes('enabled = true') || !codexLbConfig.includes('default_profile = "sks-fast-high"') || !/\[profiles\.sks-fast-high\][\s\S]*?service_tier = "fast"/.test(codexLbConfig) || codexLbConfig.includes('fast_default_opt_out = true') || hasTopLevelCodexModeLock(codexLbConfig)) throw new Error('selftest failed: codex-lb setup did not preserve Codex App Fast mode defaults, force GPT-5.5, or migrate the hooks feature flag');
2310
2314
  const codexLbLaunch = codexLaunchCommand(tmp, 'codex', []);
2311
2315
  if (!codexLbLaunch.includes('sks-codex-lb.env')) throw new Error('selftest failed: tmux launch command does not source codex-lb env file');
2316
+ if (!codexLbLaunch.includes("'--model' 'gpt-5.5'")) throw new Error('selftest failed: tmux launch command without args did not force GPT-5.5');
2312
2317
  if (!codexLbLaunch.includes('SKS_TMUX_LOGO_ANIMATION') || !codexLbLaunch.includes('SNEAKOSCOPE CODEX')) throw new Error('selftest failed: tmux launch command does not include the animated SKS logo intro');
2313
2318
  const madLaunchSource = await safeReadText(path.join(packageRoot(), 'src', 'cli', 'main.mjs'));
2314
2319
  if (!madLaunchSource.includes('const lb = await maybePromptCodexLbSetupForLaunch(args)') || !madLaunchSource.includes("const launchLb = lb.status === 'present'") || !madLaunchSource.includes('codexLbImmediateLaunchOpts(cleanArgs, launchLb')) throw new Error('selftest failed: MAD launch does not sync codex-lb auth and fresh-session launch options');
@@ -2353,7 +2358,7 @@ async function selftest() {
2353
2358
  if (!String(openClawAutoUpdate.stdout || '').includes('Codex CLI ready: 0.1.0 -> codex-cli 99.0.0')) throw new Error('selftest failed: OpenClaw mode did not auto-approve Codex CLI update before tmux launch');
2354
2359
  const remoteControlBin = path.join(tmp, 'remote-control-bin');
2355
2360
  await ensureDir(remoteControlBin);
2356
- await writeTextAtomic(path.join(remoteControlBin, 'codex'), '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "codex-cli 0.130.0"; exit 0; fi\nif [ "$1" = "remote-control" ]; then echo "remote-control $*"; exit 0; fi\necho "unexpected codex $*" >&2\nexit 2\n');
2361
+ await writeTextAtomic(path.join(remoteControlBin, 'codex'), '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "codex-cli 0.130.0"; exit 0; fi\nif [ "$1" = "remote-control" ]; then shift; for arg in "$@"; do if [ "$arg" = "--model" ]; then echo "remote-control rejects --model" >&2; exit 64; fi; done; echo "remote-control $*"; exit 0; fi\necho "unexpected codex $*" >&2\nexit 2\n');
2357
2362
  await fsp.chmod(path.join(remoteControlBin, 'codex'), 0o755);
2358
2363
  const remoteControlStatus = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'codex-app', 'remote-control', '--dry-run', '--json'], {
2359
2364
  cwd: globalCwd,
@@ -2364,6 +2369,14 @@ async function selftest() {
2364
2369
  if (remoteControlStatus.code !== 0) throw new Error(`selftest failed: Codex remote-control status exited ${remoteControlStatus.code}: ${remoteControlStatus.stderr}`);
2365
2370
  const remoteControlJson = JSON.parse(remoteControlStatus.stdout);
2366
2371
  if (!remoteControlJson.ok || remoteControlJson.min_version !== '0.130.0' || !String(remoteControlJson.command || '').includes('remote-control')) throw new Error('selftest failed: Codex remote-control status did not report 0.130.0 readiness');
2372
+ const remoteControlLaunch = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'codex-app', 'remote-control', '--', '--model', 'gpt-5.4-mini', '-c', 'model="gpt-5.4"', '--example'], {
2373
+ cwd: globalCwd,
2374
+ env: { SKS_GLOBAL_ROOT: globalRuntimeRoot, PATH: remoteControlBin },
2375
+ timeoutMs: 15000,
2376
+ maxOutputBytes: 64 * 1024
2377
+ });
2378
+ const remoteControlLaunchText = `${remoteControlLaunch.stdout}\n${remoteControlLaunch.stderr}`;
2379
+ if (remoteControlLaunch.code !== 0 || remoteControlLaunchText.includes('gpt-5.4') || remoteControlLaunchText.includes('--model') || !remoteControlLaunchText.includes('-c model="gpt-5.5"')) throw new Error('selftest failed: Codex remote-control passthrough did not force GPT-5.5 with config syntax');
2367
2380
  const remoteControlOldBin = path.join(tmp, 'remote-control-old-bin');
2368
2381
  await ensureDir(remoteControlOldBin);
2369
2382
  await writeTextAtomic(path.join(remoteControlOldBin, 'codex'), '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "codex-cli 0.129.0"; exit 0; fi\necho "unexpected codex $*" >&2\nexit 2\n');
@@ -2804,6 +2817,10 @@ async function selftest() {
2804
2817
  if (hookTeamState.phase !== 'TEAM_PARALLEL_ANALYSIS_SCOUTING' || hookTeamState.implementation_allowed === false || !hookTeamState.team_plan_ready) throw new Error('selftest failed: $Team hook did not prepare direct Team mission');
2805
2818
  if (!hookTeamState.pipeline_plan_ready || !(await exists(path.join(missionDir(hookTeamTmp, hookTeamState.mission_id), PIPELINE_PLAN_ARTIFACT)))) throw new Error('selftest failed: $Team hook did not write a pipeline plan');
2806
2819
  if (!(await exists(path.join(missionDir(hookTeamTmp, hookTeamState.mission_id), 'team-plan.json')))) throw new Error('selftest failed: Team plan was not created directly');
2820
+ const hookForbiddenModelResult = await runProcess(process.execPath, [hookBin, 'hook', 'user-prompt-submit'], { cwd: hookTeamTmp, input: JSON.stringify({ cwd: hookTeamTmp, prompt: '$Team should be blocked before route work', model: 'gpt-5.5', metadata: { client: { modelId: 'gpt-5.4-mini' } } }), env: { SKS_DISABLE_UPDATE_CHECK: '1' }, timeoutMs: 15000, maxOutputBytes: 128 * 1024 });
2821
+ if (hookForbiddenModelResult.code !== 0) throw new Error(`selftest failed: forbidden model hook exited ${hookForbiddenModelResult.code}: ${hookForbiddenModelResult.stderr}`);
2822
+ const hookForbiddenModelJson = JSON.parse(hookForbiddenModelResult.stdout);
2823
+ if (hookForbiddenModelJson.decision !== 'block' || !String(hookForbiddenModelJson.reason || '').includes('gpt-5.5') || !String(hookForbiddenModelJson.reason || '').includes('gpt-5.4-mini')) throw new Error('selftest failed: hook did not block GPT-5.4 client model metadata');
2807
2824
  const hookTeamPendingResult = await runProcess(process.execPath, [hookBin, 'hook', 'user-prompt-submit'], { cwd: hookTeamTmp, input: JSON.stringify({ cwd: hookTeamTmp, prompt: '$Team 새 작업으로 넘어가' }), env: { SKS_DISABLE_UPDATE_CHECK: '1' }, timeoutMs: 15000, maxOutputBytes: 256 * 1024 });
2808
2825
  if (hookTeamPendingResult.code !== 0) throw new Error(`selftest failed: pending clarification hook exited ${hookTeamPendingResult.code}: ${hookTeamPendingResult.stderr}`);
2809
2826
  const hookTeamPendingJson = JSON.parse(hookTeamPendingResult.stdout);
@@ -3497,6 +3514,12 @@ async function selftest() {
3497
3514
  const supabaseWritePayloadClass = classifyToolPayload({ tool_name: 'mcp__supabase__execute_sql', sql: "update users set name = 'x' where id = '1';" });
3498
3515
  if (supabaseWritePayloadClass.level !== 'write' || !supabaseWritePayloadClass.toolReasons.includes('database_tool')) throw new Error('selftest failed: Supabase execute_sql write classification was weakened');
3499
3516
  if (classifyCommand('supabase db reset').level !== 'destructive') throw new Error('selftest failed: supabase db reset not detected');
3517
+ const supabaseMigrationApplyClass = classifyCommand('supabase migration up --linked');
3518
+ if (supabaseMigrationApplyClass.level !== 'write' || !supabaseMigrationApplyClass.reasons.includes('supabase_migration_apply')) throw new Error('selftest failed: supabase migration apply was not classified as DB write');
3519
+ const supabaseDbPushClass = classifyCommand('supabase db push');
3520
+ if (supabaseDbPushClass.level !== 'write' || !supabaseDbPushClass.reasons.includes('supabase_db_push')) throw new Error('selftest failed: supabase db push was not classified as migration apply work');
3521
+ const supabaseApplyMigrationToolClass = classifyToolPayload({ tool_name: 'mcp__supabase__apply_migration', name: 'add_selftest_table' });
3522
+ if (supabaseApplyMigrationToolClass.level !== 'write' || !supabaseApplyMigrationToolClass.toolReasons.includes('migration_apply_tool')) throw new Error('selftest failed: Supabase apply_migration tool was not classified as DB write');
3500
3523
  const dbDecision = await checkDbOperation(tmp, { mission_id: id }, { tool_name: 'mcp__supabase__execute_sql', sql: 'drop table users;' }, { duringNoQuestion: true });
3501
3524
  if (dbDecision.action !== 'block') throw new Error('selftest failed: destructive MCP SQL allowed');
3502
3525
  const computerUseDecision = await checkDbOperation(tmp, { mission_id: id }, { tool_name: 'mcp__computer_use__open_app', bundle_id: 'com.microsoft.edgemac', action: 'open_app' }, { duringNoQuestion: true });
@@ -3509,6 +3532,17 @@ async function selftest() {
3509
3532
  if (madColumnCleanupDecision.action !== 'allow' || !madColumnCleanupDecision.mad_sks?.permission_profile?.allowed?.includes('direct_execute_sql_writes')) throw new Error('selftest failed: MAD-SKS column cleanup was not allowed through the modular permission gate');
3510
3533
  const madLiveDmlDecision = await checkDbOperation(tmp, madState, { tool_name: 'mcp__supabase__execute_sql', sql: "update users set name = 'fixed' where id = 'selftest';" }, { duringNoQuestion: false });
3511
3534
  if (madLiveDmlDecision.action !== 'allow' || !madLiveDmlDecision.mad_sks?.live_server_writes_allowed) throw new Error('selftest failed: MAD-SKS targeted live DML was not allowed');
3535
+ const madMigrationUpDecision = await checkDbOperation(tmp, madState, { command: 'supabase migration up --linked' }, { duringNoQuestion: true });
3536
+ if (madMigrationUpDecision.action !== 'allow' || !madMigrationUpDecision.mad_sks?.permission_profile?.allowed?.includes('migration_apply_when_required')) throw new Error('selftest failed: MAD-SKS did not allow Supabase migration up during no-question execution');
3537
+ const madDbPushDecision = await checkDbOperation(tmp, madState, { command: 'supabase db push' }, { duringNoQuestion: true });
3538
+ if (madDbPushDecision.action !== 'allow') throw new Error('selftest failed: MAD-SKS did not allow Supabase db push migration application');
3539
+ const madApplyMigrationDecision = await checkDbOperation(tmp, madState, { tool_name: 'mcp__supabase__apply_migration', name: 'add_selftest_table' }, { duringNoQuestion: true });
3540
+ if (madApplyMigrationDecision.action !== 'allow') throw new Error('selftest failed: MAD-SKS did not allow Supabase MCP apply_migration');
3541
+ const madTmuxMission = await createMission(tmp, { mode: 'mad-sks', prompt: 'sks --mad migration selftest' });
3542
+ await writeJsonAtomic(path.join(madTmuxMission.dir, 'mad-sks-gate.json'), { schema_version: 1, passed: false, mad_sks_permission_active: true, migration_apply_allowed: true });
3543
+ const madTmuxState = { mission_id: madTmuxMission.id, mode: 'MADSKS', route_command: '$MAD-SKS', stop_gate: 'mad-sks-gate.json', mad_sks_active: true, mad_sks_modifier: true, mad_sks_gate_file: 'mad-sks-gate.json', migration_apply_allowed: true };
3544
+ const madTmuxMigrationDecision = await checkDbOperation(tmp, madTmuxState, { command: 'supabase migration up --linked' }, { duringNoQuestion: true });
3545
+ if (madTmuxMigrationDecision.action !== 'allow') throw new Error('selftest failed: sks --mad state did not allow Supabase migration application');
3512
3546
  const tableRemovalSql = 'dr' + 'op table users;';
3513
3547
  const madTableRemovalDecision = await checkDbOperation(tmp, madState, { tool_name: 'mcp__supabase__execute_sql', sql: tableRemovalSql }, { duringNoQuestion: false });
3514
3548
  if (madTableRemovalDecision.action !== 'block') throw new Error('selftest failed: MAD-SKS catastrophic table removal was not blocked');
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { exists, packageRoot, runProcess, which } from './fsx.mjs';
3
+ import { forceGpt55CodexArgs } from './codex-model-guard.mjs';
3
4
 
4
5
  export async function findCodexBinary() {
5
6
  const env = process.env.SKS_CODEX_BIN || process.env.DCODEX_CODEX_BIN || process.env.CODEX_BIN;
@@ -25,17 +26,22 @@ export async function getCodexInfo() {
25
26
  return { bin, version, available: Boolean(bin) };
26
27
  }
27
28
 
28
- export async function runCodexExec({ root, prompt, outputFile, json = true, profile = null, extraArgs = [], onStdout, onStderr, logDir = null, stdoutFile = null, stderrFile = null, maxBufferBytes = 256 * 1024, timeoutMs = null }) {
29
- const bin = await findCodexBinary();
30
- if (!bin) {
31
- return { code: 127, stdout: '', stderr: 'Codex CLI not found. Install @openai/codex or set SKS_CODEX_BIN.' };
32
- }
29
+ export function buildCodexExecArgs({ root, prompt, outputFile, json = true, profile = null, extraArgs = [] }) {
33
30
  const args = ['exec', '--cd', root];
34
31
  if (profile) args.push('--profile', profile);
35
32
  if (json) args.push('--json');
36
33
  if (outputFile) args.push('--output-last-message', outputFile);
37
- args.push(...extraArgs);
34
+ args.push(...forceGpt55CodexArgs(extraArgs));
38
35
  args.push(prompt);
36
+ return args;
37
+ }
38
+
39
+ export async function runCodexExec({ root, prompt, outputFile, json = true, profile = null, extraArgs = [], onStdout, onStderr, logDir = null, stdoutFile = null, stderrFile = null, maxBufferBytes = 256 * 1024, timeoutMs = null }) {
40
+ const bin = await findCodexBinary();
41
+ if (!bin) {
42
+ return { code: 127, stdout: '', stderr: 'Codex CLI not found. Install @openai/codex or set SKS_CODEX_BIN.' };
43
+ }
44
+ const args = buildCodexExecArgs({ root, prompt, outputFile, json, profile, extraArgs });
39
45
  const effectiveTimeoutMs = Number(timeoutMs || process.env.SKS_CODEX_TIMEOUT_MS || process.env.DCODEX_CODEX_TIMEOUT_MS || 30 * 60 * 1000);
40
46
  return runProcess(bin, args, {
41
47
  cwd: root,
@@ -0,0 +1,50 @@
1
+ export const REQUIRED_CODEX_MODEL = 'gpt-5.5';
2
+
3
+ const MODEL_VALUE_FLAGS = new Set(['--model', '-m']);
4
+ const CONFIG_VALUE_FLAGS = new Set(['-c', '--config']);
5
+
6
+ function isModelConfigOverride(value = '') {
7
+ return /^model\s*=/.test(String(value || '').trim());
8
+ }
9
+
10
+ function stripCodexModelOverrides(args = []) {
11
+ const out = [];
12
+ const input = Array.isArray(args) ? args : [];
13
+ for (let i = 0; i < input.length; i += 1) {
14
+ const arg = String(input[i]);
15
+ if (MODEL_VALUE_FLAGS.has(arg)) {
16
+ i += 1;
17
+ continue;
18
+ }
19
+ if (arg.startsWith('--model=') || arg.startsWith('-m=')) continue;
20
+ if (CONFIG_VALUE_FLAGS.has(arg)) {
21
+ const value = i + 1 < input.length ? String(input[i + 1]) : '';
22
+ if (isModelConfigOverride(value)) {
23
+ i += 1;
24
+ continue;
25
+ }
26
+ out.push(arg);
27
+ if (i + 1 < input.length) out.push(String(input[++i]));
28
+ continue;
29
+ }
30
+ if (arg.startsWith('-c=') || arg.startsWith('--config=')) {
31
+ const value = arg.slice(arg.indexOf('=') + 1);
32
+ if (isModelConfigOverride(value)) continue;
33
+ }
34
+ out.push(arg);
35
+ }
36
+ return out;
37
+ }
38
+
39
+ export function forceGpt55CodexArgs(args = []) {
40
+ return ['--model', REQUIRED_CODEX_MODEL, ...stripCodexModelOverrides(args)];
41
+ }
42
+
43
+ export function forceGpt55CodexConfigArgs(args = []) {
44
+ return ['-c', `model="${REQUIRED_CODEX_MODEL}"`, ...stripCodexModelOverrides(args)];
45
+ }
46
+
47
+ export function isForbiddenCodexModel(value = '') {
48
+ const model = String(value || '').trim().toLowerCase();
49
+ return /^gpt-5\./.test(model) && model !== REQUIRED_CODEX_MODEL;
50
+ }
@@ -137,9 +137,14 @@ export function classifyCommand(command = '') {
137
137
  const low = c.toLowerCase();
138
138
  const reasons = [];
139
139
  if (!low.trim()) return { level: 'none', kind: 'none', reasons: [], command: c };
140
+ const supabaseMigrationApply = [];
141
+ if (/\bsupabase\s+migration\s+up\b/.test(low)) supabaseMigrationApply.push('supabase_migration_up', 'supabase_migration_apply');
142
+ if (/\bsupabase\s+db\s+push\b/.test(low)) supabaseMigrationApply.push('supabase_db_push', 'supabase_migration_apply');
143
+ const supabaseMigrationRead = [];
144
+ if (/\bsupabase\s+db\s+(diff|pull)\b/.test(low)) supabaseMigrationRead.push('supabase_migration_schema_read');
145
+ if (/\bsupabase\s+migration\s+(list|new|squash)\b/.test(low)) supabaseMigrationRead.push('supabase_migration_file_work');
140
146
  const hard = [
141
147
  [/\bsupabase\s+db\s+reset\b/, 'supabase_db_reset'],
142
- [/\bsupabase\s+db\s+push\b/, 'supabase_db_push'],
143
148
  [/\bsupabase\s+migration\s+repair\b/, 'supabase_migration_repair'],
144
149
  [/\bprisma\s+migrate\s+reset\b/, 'prisma_migrate_reset'],
145
150
  [/\bprisma\s+db\s+push\b/, 'prisma_db_push'],
@@ -152,6 +157,25 @@ export function classifyCommand(command = '') {
152
157
  const maybeSql = extractSqlLiterals(c).join('\n');
153
158
  const sqlClass = maybeSql ? classifySql(maybeSql) : { level: 'none', reasons: [] };
154
159
  if (reasons.length) return { level: 'destructive', kind: 'db_command', reasons, sql: sqlClass, command: c };
160
+ if (supabaseMigrationApply.length) {
161
+ const level = sqlClass.level === 'destructive' ? 'destructive' : 'write';
162
+ return {
163
+ level,
164
+ kind: 'db_migration',
165
+ reasons: [...new Set([...supabaseMigrationApply, ...(sqlClass.reasons || [])])],
166
+ sql: sqlClass,
167
+ command: c
168
+ };
169
+ }
170
+ if (supabaseMigrationRead.length && !['write', 'destructive'].includes(sqlClass.level)) {
171
+ return {
172
+ level: 'safe',
173
+ kind: 'db_migration',
174
+ reasons: [...new Set([...supabaseMigrationRead, ...(sqlClass.reasons || [])])],
175
+ sql: sqlClass,
176
+ command: c
177
+ };
178
+ }
155
179
  if (/\b(psql|supabase|prisma|drizzle-kit|knex|sequelize)\b/.test(low)) {
156
180
  if (sqlClass.level === 'destructive' || sqlClass.level === 'write') return { level: sqlClass.level, kind: 'db_command', reasons: sqlClass.reasons, sql: sqlClass, command: c };
157
181
  return { level: sqlClass.level === 'safe' ? 'safe' : 'possible_db', kind: 'db_command', reasons: sqlClass.reasons, sql: sqlClass, command: c };
@@ -199,10 +223,15 @@ export function classifyToolPayload(payload = {}) {
199
223
  const sqlClass = classifySql(combined);
200
224
  const commandClass = classifyCommand(strings.find((s) => /\b(supabase|psql|prisma|drizzle|knex|sequelize)\b/i.test(s)) || '');
201
225
  const toolReasons = [];
226
+ const reasons = [];
202
227
  if (/\b(apply_patch|edit|write|create|remove|rename|str_replace|file_write|fs_write)\b/i.test(toolName) && !/supabase|postgres|database|execute_sql|apply_migration|sql_query|db_|_db\b|migration/.test(toolName)) {
203
- return { level: 'none', toolName, toolReasons, sql: sqlClass, command: commandClass, stringsExamined: strings.length };
228
+ return { level: 'none', toolName, toolReasons, reasons, sql: sqlClass, command: commandClass, stringsExamined: strings.length };
204
229
  }
205
230
  if (/supabase|postgres|database|execute_sql|apply_migration|sql_query|db_|_db\b|migration/.test(toolName)) toolReasons.push('database_tool');
231
+ if (/apply_migration|migration_apply/i.test(toolName)) {
232
+ toolReasons.push('migration_apply_tool');
233
+ reasons.push('supabase_migration_apply');
234
+ }
206
235
  if (/delete_project|pause_project|restore_project|delete_branch|reset_branch|merge_branch/.test(toolName)) toolReasons.push('dangerous_supabase_management_tool');
207
236
  let level = 'none';
208
237
  for (const candidate of [sqlClass.level, commandClass.level]) {
@@ -211,8 +240,9 @@ export function classifyToolPayload(payload = {}) {
211
240
  else if ((candidate === 'safe' || candidate === 'possible_db') && level === 'none') level = candidate;
212
241
  }
213
242
  if (toolReasons.includes('dangerous_supabase_management_tool')) level = 'destructive';
243
+ if (toolReasons.includes('migration_apply_tool') && level !== 'destructive') level = 'write';
214
244
  if (toolReasons.includes('database_tool') && level === 'none') level = 'possible_db';
215
- return { level, toolName, toolReasons, sql: sqlClass, command: commandClass, stringsExamined: strings.length };
245
+ return { level, toolName, toolReasons, reasons, sql: sqlClass, command: commandClass, stringsExamined: strings.length };
216
246
  }
217
247
 
218
248
  function contractAllowsDbWrite(contract = {}) {
@@ -290,6 +320,12 @@ export function evaluateDbSafety({ classification, policy = DEFAULT_DB_SAFETY_PO
290
320
  }
291
321
  if (cls.level === 'destructive') reasons.push('destructive_database_operation_blocked_always');
292
322
  if (cls.level === 'write') {
323
+ const reasonSet = new Set([
324
+ ...(cls.reasons || []),
325
+ ...(cls.sql?.reasons || []),
326
+ ...(cls.command?.reasons || [])
327
+ ]);
328
+ if (reasonSet.has('supabase_db_push')) reasons.push('supabase_db_push_requires_active_mad_sks');
293
329
  if (effective.mode === 'read_only_only') reasons.push('database_write_mode_is_read_only_only');
294
330
  if (effective.env === 'production' || effective.env === 'production_read_only') reasons.push('production_database_writes_forbidden');
295
331
  if (!['local_dev', 'preview_branch', 'supabase_branch'].includes(effective.env)) reasons.push('database_write_target_not_local_or_branch');
package/src/core/fsx.mjs CHANGED
@@ -5,7 +5,7 @@ import os from 'node:os';
5
5
  import crypto from 'node:crypto';
6
6
  import { spawn } from 'node:child_process';
7
7
 
8
- export const PACKAGE_VERSION = '0.7.53';
8
+ export const PACKAGE_VERSION = '0.7.55';
9
9
  export const DEFAULT_PROCESS_TAIL_BYTES = 256 * 1024;
10
10
  export const DEFAULT_PROCESS_TIMEOUT_MS = 30 * 60 * 1000;
11
11
 
@@ -6,6 +6,7 @@ import { checkDbOperation, dbBlockReason, handleMadSksUserConfirmation } from '.
6
6
  import { checkHarnessModification, harnessGuardBlockReason } from './harness-guard.mjs';
7
7
  import { activeRouteContext, evaluateStop, prepareRoute, promptPipelineContext as routePipelineContext, recordContext7Evidence, recordSubagentEvidence, routePrompt } from './pipeline.mjs';
8
8
  import { classifyToolError } from './evaluation.mjs';
9
+ import { REQUIRED_CODEX_MODEL, isForbiddenCodexModel } from './codex-model-guard.mjs';
9
10
 
10
11
  const TEAM_DIGEST_MAX_EVENTS = 4;
11
12
  const TEAM_DIGEST_MESSAGE_CHARS = 180;
@@ -91,7 +92,11 @@ export async function hookMain(name) {
91
92
  const root = await projectRoot(payload.cwd || process.cwd());
92
93
  const state = await loadState(root);
93
94
  const noQuestion = isNoQuestionRunning(state);
94
- if (name === 'user-prompt-submit') return hookUserPrompt(root, state, payload, noQuestion);
95
+ if (name === 'user-prompt-submit') {
96
+ const modelBlock = blockForbiddenClientModel(payload);
97
+ if (modelBlock) return modelBlock;
98
+ return hookUserPrompt(root, state, payload, noQuestion);
99
+ }
95
100
  if (name === 'pre-tool') return hookPreTool(root, state, payload, noQuestion);
96
101
  if (name === 'post-tool') return hookPostTool(root, state, payload, noQuestion);
97
102
  if (name === 'permission-request') return hookPermission(root, state, payload, noQuestion);
@@ -99,6 +104,43 @@ export async function hookMain(name) {
99
104
  return { continue: true };
100
105
  }
101
106
 
107
+ function blockForbiddenClientModel(payload = {}) {
108
+ const model = forbiddenClientModelFromPayload(payload);
109
+ if (!model || !isForbiddenCodexModel(model)) return null;
110
+ return {
111
+ decision: 'block',
112
+ reason: `SKS requires ${REQUIRED_CODEX_MODEL}; client payload requested ${model}. Switch the Codex client/session model to ${REQUIRED_CODEX_MODEL} and retry.`
113
+ };
114
+ }
115
+
116
+ function forbiddenClientModelFromPayload(payload = {}) {
117
+ const candidates = [
118
+ payload.model,
119
+ payload.model_id,
120
+ payload.modelId,
121
+ payload.client_model,
122
+ payload.clientModel,
123
+ ...clientModelCandidates(payload.client),
124
+ ...clientModelCandidates(payload.metadata),
125
+ ...clientModelCandidates(payload.context),
126
+ ...clientModelCandidates(payload.thread),
127
+ ...clientModelCandidates(payload.session)
128
+ ];
129
+ return candidates.find((value) => typeof value === 'string' && isForbiddenCodexModel(value)) || '';
130
+ }
131
+
132
+ function clientModelCandidates(value, depth = 0) {
133
+ if (!value || typeof value !== 'object' || depth > 4) return [];
134
+ const out = [];
135
+ for (const key of ['model', 'model_id', 'modelId', 'client_model', 'clientModel']) {
136
+ if (typeof value[key] === 'string') out.push(value[key]);
137
+ }
138
+ for (const key of ['client', 'metadata', 'context', 'thread', 'session']) {
139
+ out.push(...clientModelCandidates(value[key], depth + 1));
140
+ }
141
+ return out;
142
+ }
143
+
102
144
  async function hookUserPrompt(root, state, payload, noQuestion) {
103
145
  if (!noQuestion) {
104
146
  const prompt = extractUserPrompt(payload);
@@ -4,6 +4,7 @@ import { spawnSync } from 'node:child_process';
4
4
  import { exists, nowIso, packageRoot, readJson, runProcess, sha256, sksRoot, which, writeJsonAtomic } from './fsx.mjs';
5
5
  import { getCodexInfo } from './codex-adapter.mjs';
6
6
  import { codexAppIntegrationStatus, formatCodexAppStatus } from './codex-app.mjs';
7
+ import { REQUIRED_CODEX_MODEL, forceGpt55CodexArgs } from './codex-model-guard.mjs';
7
8
  import { MIN_TEAM_REVIEWER_LANES } from './team-review-policy.mjs';
8
9
 
9
10
  export const SKS_TMUX_LOGO = [
@@ -114,7 +115,7 @@ const SKS_TMUX_LOGO_ANIMATION_STEPS = Object.freeze([
114
115
  { frame: 9, color: '51', bold: true, delay: '0.16' }
115
116
  ]);
116
117
 
117
- export const DEFAULT_SKS_CODEX_MODEL = 'gpt-5.5';
118
+ export const DEFAULT_SKS_CODEX_MODEL = REQUIRED_CODEX_MODEL;
118
119
  export const DEFAULT_SKS_CODEX_REASONING = 'high';
119
120
 
120
121
  export function defaultCodexLaunchArgs(env = process.env) {
@@ -200,7 +201,7 @@ export function tmuxStatusKind(tmux = {}) {
200
201
  }
201
202
 
202
203
  export function codexLaunchCommand(root, codexBin, codexArgs = []) {
203
- const extraArgs = Array.isArray(codexArgs) ? codexArgs : [];
204
+ const extraArgs = forceGpt55CodexArgs(codexArgs);
204
205
  return [
205
206
  sksLogoIntroCommand(codexBin),
206
207
  `printf '\\nProject: %s\\n' ${shellEscape(root)}`,
@@ -322,7 +323,7 @@ export async function buildTmuxLaunchPlan(opts = {}) {
322
323
  const codex = opts.codex || await getCodexInfo().catch(() => ({}));
323
324
  const tmux = opts.tmux || await tmuxReadiness(opts);
324
325
  const app = opts.app || await codexAppIntegrationStatus({ codex });
325
- const codexArgs = Array.isArray(opts.codexArgs) ? opts.codexArgs : defaultCodexLaunchArgs(opts.env || process.env);
326
+ const codexArgs = forceGpt55CodexArgs(Array.isArray(opts.codexArgs) ? opts.codexArgs : defaultCodexLaunchArgs(opts.env || process.env));
326
327
  return {
327
328
  root,
328
329
  session,