diff --git a/src/cliproxy/antigravity-responsibility.ts b/src/cliproxy/antigravity-responsibility.ts index 0197e651..d08d502b 100644 --- a/src/cliproxy/antigravity-responsibility.ts +++ b/src/cliproxy/antigravity-responsibility.ts @@ -10,6 +10,7 @@ import { createInterface, Interface } from 'readline'; import { fail, info, ok, warn } from '../utils/ui'; +import { getCliproxySafetyConfig } from '../config/unified-config-loader'; export const ANTIGRAVITY_RISK_ISSUE_URL = 'https://github.com/kaitranntt/ccs/issues/622'; export const ANTIGRAVITY_PROJECT_ID_ISSUE_URL = 'https://github.com/kaitranntt/ccs/issues/619'; @@ -47,6 +48,19 @@ function isTruthyEnv(value: string | undefined): boolean { return normalized === '1' || normalized === 'true' || normalized === 'yes'; } +export function isAntigravityResponsibilityBypassEnabled(): boolean { + if (isTruthyEnv(process.env.CCS_ACCEPT_AGY_RISK)) { + return true; + } + + try { + const safety = getCliproxySafetyConfig(); + return safety.antigravity_ack_bypass === true; + } catch { + return false; + } +} + function askQuestion(rl: Interface, prompt: string): Promise { return new Promise((resolve) => { let settled = false; @@ -160,7 +174,7 @@ export function validateAntigravityRiskAcknowledgement(payload: unknown): Valida export async function ensureCliAntigravityResponsibility( options: EnsureCliRiskOptions ): Promise { - if (options.acceptedByFlag || isTruthyEnv(process.env.CCS_ACCEPT_AGY_RISK)) { + if (options.acceptedByFlag || isAntigravityResponsibilityBypassEnabled()) { return true; } diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 8f82351c..29f6ccef 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -18,10 +18,12 @@ import { DEFAULT_CURSOR_CONFIG, DEFAULT_GLOBAL_ENV, DEFAULT_CLIPROXY_SERVER_CONFIG, + DEFAULT_CLIPROXY_SAFETY_CONFIG, DEFAULT_QUOTA_MANAGEMENT_CONFIG, DEFAULT_THINKING_CONFIG, DEFAULT_DASHBOARD_AUTH_CONFIG, DEFAULT_IMAGE_ANALYSIS_CONFIG, + CLIProxySafetyConfig, GlobalEnvConfig, ThinkingConfig, DashboardAuthConfig, @@ -249,6 +251,11 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { request_log: partial.cliproxy?.logging?.request_log ?? defaults.cliproxy.logging?.request_log ?? false, }, + safety: { + antigravity_ack_bypass: + partial.cliproxy?.safety?.antigravity_ack_bypass ?? + DEFAULT_CLIPROXY_SAFETY_CONFIG.antigravity_ack_bypass, + }, // Auth config - preserve user values, no defaults (uses constants as fallback) auth: partial.cliproxy?.auth, // Backend selection - validate and preserve user choice (original vs plus) @@ -892,6 +899,19 @@ export function getGlobalEnvConfig(): GlobalEnvConfig { }; } +/** + * Get cliproxy safety configuration. + * Returns defaults if not configured. + */ +export function getCliproxySafetyConfig(): CLIProxySafetyConfig { + const config = loadOrCreateUnifiedConfig(); + return { + antigravity_ack_bypass: + config.cliproxy?.safety?.antigravity_ack_bypass ?? + DEFAULT_CLIPROXY_SAFETY_CONFIG.antigravity_ack_bypass, + }; +} + /** * Get thinking configuration. * Returns defaults if not configured. diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 1b1d1d9c..40b308c9 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -156,6 +156,22 @@ export interface CLIProxyLoggingConfig { request_log?: boolean; } +/** + * CLIProxy safety configuration. + * Controls high-risk flow safeguards for supported providers. + */ +export interface CLIProxySafetyConfig { + /** Allow skipping AGY responsibility acknowledgement flow (default: false) */ + antigravity_ack_bypass?: boolean; +} + +/** + * Default CLIProxy safety configuration. + */ +export const DEFAULT_CLIPROXY_SAFETY_CONFIG: CLIProxySafetyConfig = { + antigravity_ack_bypass: false, +}; + /** * Token refresh configuration. * Manages background token refresh worker settings. @@ -187,6 +203,8 @@ export interface CLIProxyConfig { variants: Record; /** Logging configuration (disabled by default) */ logging?: CLIProxyLoggingConfig; + /** Safety controls for high-risk provider flows */ + safety?: CLIProxySafetyConfig; /** Kiro: disable incognito browser mode (use normal browser to save credentials) */ kiro_no_incognito?: boolean; /** Global auth configuration for CLIProxyAPI */ @@ -777,6 +795,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { enabled: false, request_log: false, }, + safety: { ...DEFAULT_CLIPROXY_SAFETY_CONFIG }, auto_sync: true, }, preferences: { diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 14026eca..6033f58f 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -46,7 +46,10 @@ import { import { getOAuthFlowType } from '../../cliproxy/provider-capabilities'; import type { CLIProxyProvider } from '../../cliproxy/types'; import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; -import { validateAntigravityRiskAcknowledgement } from '../../cliproxy/antigravity-responsibility'; +import { + validateAntigravityRiskAcknowledgement, + isAntigravityResponsibilityBypassEnabled, +} from '../../cliproxy/antigravity-responsibility'; const router = Router(); @@ -406,7 +409,7 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise // ==================== Auth Tokens ==================== +/** + * GET /api/settings/auth/antigravity-risk - Get AGY responsibility bypass setting + */ +router.get('/auth/antigravity-risk', (_req: Request, res: Response): void => { + try { + const config = loadOrCreateUnifiedConfig(); + res.json({ + antigravityAckBypass: config.cliproxy?.safety?.antigravity_ack_bypass === true, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/settings/auth/antigravity-risk - Update AGY responsibility bypass setting + */ +router.put('/auth/antigravity-risk', (req: Request, res: Response): void => { + try { + const { antigravityAckBypass } = req.body as { antigravityAckBypass?: unknown }; + + if (typeof antigravityAckBypass !== 'boolean') { + res.status(400).json({ error: 'antigravityAckBypass must be a boolean' }); + return; + } + + const config = loadOrCreateUnifiedConfig(); + config.cliproxy.safety = { + ...(config.cliproxy.safety ?? {}), + antigravity_ack_bypass: antigravityAckBypass, + }; + saveUnifiedConfig(config); + + res.json({ + success: true, + antigravityAckBypass, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + /** * GET /api/settings/auth/tokens - Get current auth token status (masked) */ diff --git a/tests/unit/cliproxy/antigravity-responsibility.test.ts b/tests/unit/cliproxy/antigravity-responsibility.test.ts index 0ae0ca7c..c84b4ff6 100644 --- a/tests/unit/cliproxy/antigravity-responsibility.test.ts +++ b/tests/unit/cliproxy/antigravity-responsibility.test.ts @@ -1,12 +1,44 @@ -import { describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import { ANTIGRAVITY_ACK_PHRASE, ANTIGRAVITY_ACK_VERSION, hasAntigravityRiskAcceptanceFlag, + isAntigravityResponsibilityBypassEnabled, validateAntigravityRiskAcknowledgement, } from '../../../src/cliproxy/antigravity-responsibility'; describe('antigravity-responsibility', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + let originalAgyRiskEnv: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-agy-risk-test-')); + originalCcsHome = process.env.CCS_HOME; + originalAgyRiskEnv = process.env.CCS_ACCEPT_AGY_RISK; + process.env.CCS_HOME = tempHome; + delete process.env.CCS_ACCEPT_AGY_RISK; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (originalAgyRiskEnv !== undefined) { + process.env.CCS_ACCEPT_AGY_RISK = originalAgyRiskEnv; + } else { + delete process.env.CCS_ACCEPT_AGY_RISK; + } + + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + it('accepts a complete acknowledgement payload', () => { const result = validateAntigravityRiskAcknowledgement({ version: ANTIGRAVITY_ACK_VERSION, @@ -75,4 +107,24 @@ describe('antigravity-responsibility', () => { expect(hasAntigravityRiskAcceptanceFlag(['--accept-antigravity-risk'])).toBeTrue(); expect(hasAntigravityRiskAcceptanceFlag(['--auth'])).toBeFalse(); }); + + it('enables bypass when CCS_ACCEPT_AGY_RISK is set', () => { + process.env.CCS_ACCEPT_AGY_RISK = 'true'; + expect(isAntigravityResponsibilityBypassEnabled()).toBeTrue(); + }); + + it('enables bypass when cliproxy safety setting is enabled', () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + `version: 8 +cliproxy: + safety: + antigravity_ack_bypass: true +` + ); + + expect(isAntigravityResponsibilityBypassEnabled()).toBeTrue(); + }); }); diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index 41041441..e426c0df 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -25,7 +25,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Loader2, ExternalLink, User, Download, Copy, Check } from 'lucide-react'; +import { Loader2, ExternalLink, User, Download, Copy, Check, ShieldAlert } from 'lucide-react'; import { useKiroImport } from '@/hooks/use-cliproxy'; import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow'; import { applyDefaultPreset } from '@/lib/preset-utils'; @@ -68,6 +68,8 @@ export function AddAccountDialog({ const [localError, setLocalError] = useState(null); const [acknowledgedRisk, setAcknowledgedRisk] = useState(false); const [agyRiskChecklist, setAgyRiskChecklist] = useState(DEFAULT_ANTIGRAVITY_RISK_CHECKLIST); + const [agyAckBypassEnabled, setAgyAckBypassEnabled] = useState(false); + const [agyAckBypassLoading, setAgyAckBypassLoading] = useState(false); const [kiroAuthMethod, setKiroAuthMethod] = useState(DEFAULT_KIRO_AUTH_METHOD); const wasAuthenticatingRef = useRef(false); const authFlow = useCliproxyAuthFlow(); @@ -75,7 +77,8 @@ export function AddAccountDialog({ const isKiro = provider === 'kiro'; const requiresSafetyAcknowledgement = provider === 'gemini'; - const requiresAgyResponsibilityFlow = provider === 'agy'; + const requiresAgyResponsibilityFlow = provider === 'agy' && !agyAckBypassEnabled; + const isAgyBypassStatePending = provider === 'agy' && agyAckBypassLoading; const isAgyRiskChecklistComplete = isAntigravityRiskChecklistComplete(agyRiskChecklist); const defaultDeviceCode = isDeviceCodeProvider(provider); const requiresNickname = isNicknameRequiredProvider(provider); @@ -92,6 +95,8 @@ export function AddAccountDialog({ setLocalError(null); setAcknowledgedRisk(false); setAgyRiskChecklist(DEFAULT_ANTIGRAVITY_RISK_CHECKLIST); + setAgyAckBypassEnabled(false); + setAgyAckBypassLoading(false); setKiroAuthMethod(DEFAULT_KIRO_AUTH_METHOD); wasAuthenticatingRef.current = false; onClose(); @@ -105,6 +110,44 @@ export function AddAccountDialog({ } }, [provider, open]); + useEffect(() => { + let cancelled = false; + + if (!open || provider !== 'agy') { + setAgyAckBypassEnabled(false); + setAgyAckBypassLoading(false); + return; + } + + const loadAgyBypassState = async () => { + try { + setAgyAckBypassLoading(true); + const response = await fetch('/api/settings/auth/antigravity-risk'); + if (!response.ok) { + throw new Error('Failed to load Antigravity power user setting'); + } + const data = (await response.json()) as { antigravityAckBypass?: boolean }; + if (!cancelled) { + setAgyAckBypassEnabled(data.antigravityAckBypass === true); + } + } catch { + if (!cancelled) { + setAgyAckBypassEnabled(false); + } + } finally { + if (!cancelled) { + setAgyAckBypassLoading(false); + } + } + }; + + loadAgyBypassState(); + + return () => { + cancelled = true; + }; + }, [open, provider]); + // When authFlow completes successfully (polling detected success), apply preset and close useEffect(() => { if (!authFlow.isAuthenticating && !authFlow.error && authFlow.provider === null && open) { @@ -153,6 +196,10 @@ export function AddAccountDialog({ * - Authorization code providers use /start-url and polling. */ const handleAuthenticate = () => { + if (isAgyBypassStatePending) { + setLocalError('Loading Antigravity safety settings. Please wait a moment and retry.'); + return; + } if (requiresAgyResponsibilityFlow && !isAgyRiskChecklistComplete) { setLocalError( 'Complete all Antigravity responsibility steps before authenticating this provider.' @@ -241,6 +288,17 @@ export function AddAccountDialog({ /> )} + {provider === 'agy' && agyAckBypassEnabled && !showAuthUI && ( +
+
+ + Power user mode enabled +
+ AGY responsibility checklist is skipped from Settings {'>'} Auth. You accept full + responsibility for OAuth/account risk. +
+ )} + {requiresSafetyAcknowledgement && !showAuthUI && ( (null); const [copiedApiKey, setCopiedApiKey] = useState(false); const [copiedSecret, setCopiedSecret] = useState(false); + const [agyAckBypass, setAgyAckBypass] = useState(false); + const [agyAckBypassLoading, setAgyAckBypassLoading] = useState(true); + const [agyAckBypassSaving, setAgyAckBypassSaving] = useState(false); // Fetch tokens const fetchTokens = useCallback(async () => { @@ -71,11 +76,28 @@ export default function AuthSection() { } }, []); + const fetchAgyAckBypass = useCallback(async () => { + try { + setAgyAckBypassLoading(true); + const response = await fetch('/api/settings/auth/antigravity-risk'); + if (!response.ok) { + throw new Error('Failed to load Antigravity power user settings'); + } + const data = (await response.json()) as { antigravityAckBypass?: boolean }; + setAgyAckBypass(data.antigravityAckBypass === true); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setAgyAckBypassLoading(false); + } + }, []); + // Load on mount useEffect(() => { fetchTokens(); + fetchAgyAckBypass(); fetchRawConfig(); - }, [fetchTokens, fetchRawConfig]); + }, [fetchTokens, fetchAgyAckBypass, fetchRawConfig]); // Clear success after timeout useEffect(() => { @@ -197,6 +219,41 @@ export default function AuthSection() { setTimeout(() => setCopiedSecret(false), 2000); }; + const saveAgyAckBypass = async (nextValue: boolean) => { + if (nextValue) { + const confirmed = window.confirm( + 'Enable Antigravity power user mode?\n\nThis disables AGY responsibility checklist prompts in CLI and dashboard. You accept full responsibility for OAuth/account risk.' + ); + if (!confirmed) return; + } + + try { + setAgyAckBypassSaving(true); + setError(null); + + const response = await fetch('/api/settings/auth/antigravity-risk', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ antigravityAckBypass: nextValue }), + }); + + if (!response.ok) { + const data = (await response.json()) as { error?: string }; + throw new Error(data.error || 'Failed to update Antigravity power user mode'); + } + + setAgyAckBypass(nextValue); + setSuccess( + nextValue ? 'Antigravity power user mode enabled.' : 'Antigravity power user mode disabled.' + ); + await fetchRawConfig(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setAgyAckBypassSaving(false); + } + }; + if (loading || !tokens) { return (
@@ -360,6 +417,29 @@ export default function AuthSection() {
{/* Actions */} +
+
+
+
+ +

Antigravity Power User Mode

+
+

+ Skip AGY responsibility checklists in Add Account and `ccs agy` flows. +

+
+ +
+

+ Use only if you fully understand the OAuth suspension/ban risk pattern (#622). CCS + cannot assume responsibility for account loss. +

+
+