Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3d3ecf1
feat(cli): add safe mode
DennisYu07 Jun 10, 2026
f4e2b3d
fix(test): add isSafeMode to test mocks and config stubs
DennisYu07 Jun 10, 2026
f69a198
fix(ui): hide memory toggles in safe mode
DennisYu07 Jun 10, 2026
c8fa93e
merge main and resolve conflict
DennisYu07 Jun 14, 2026
4b9706d
fix(cli,core): harden safe mode guards for permissions and extensions
DennisYu07 Jun 16, 2026
c57db62
Merge remote-tracking branch 'origin/main' into feat/safe-mode
DennisYu07 Jun 16, 2026
81bc81a
fix(test): add isSafeMode to config stub in gemini.test.tsx
DennisYu07 Jun 16, 2026
07ddcba
Merge branch 'main' into feat/safe-mode
yiliang114 Jun 16, 2026
10c130e
fix(cli,core): close safe-mode bypass gaps in context loading and chi…
DennisYu07 Jun 22, 2026
d4f4914
Merge branch 'main' into feat/safe-mode
DennisYu07 Jun 22, 2026
4193772
fix(test): add isSafeMode to directoryCommand mock config
DennisYu07 Jun 22, 2026
d15eb4b
Merge remote-tracking branch 'origin/main' into feat/safe-mode
DennisYu07 Jun 22, 2026
fe8bb42
fix(safe-mode): address code review feedback and improve consistency
DennisYu07 Jun 22, 2026
f1eddf4
Merge branch 'main' into feat/safe-mode
DennisYu07 Jun 22, 2026
5777de5
fix: rename safeMode.ts to safe-mode.ts for kebab-case convention
DennisYu07 Jun 22, 2026
19d4f66
Merge branch 'main' into feat/safe-mode
DennisYu07 Jun 22, 2026
666c3d2
Merge branch 'main' into feat/safe-mode
DennisYu07 Jun 22, 2026
a95f1b2
fix(safe-mode): close extension leak and restore bare-mode sleep prev…
DennisYu07 Jun 23, 2026
119eb33
fix(safe-mode): address review feedback on bare-mode extensions, env …
DennisYu07 Jun 23, 2026
b605f5e
Merge branch 'main' into feat/safe-mode
DennisYu07 Jun 23, 2026
5a7ff3f
Merge branch 'main' into feat/safe-mode
DennisYu07 Jun 23, 2026
5089059
Merge branch 'main' into feat/safe-mode
DennisYu07 Jun 23, 2026
d2615c3
Merge branch 'main' into feat/safe-mode
wenshao Jun 24, 2026
d68167e
merge: resolve conflicts between feat/safe-mode and main
qwen-code-dev-bot Jun 25, 2026
3e134b1
merge: resolve conflicts between feat/safe-mode and main
qwen-code-dev-bot Jun 29, 2026
d3af1e0
fix(core): add missing DEFAULT_SENSITIVE_SPAN_ATTRIBUTE_MAX_LENGTH to…
DennisYu07 Jun 29, 2026
06756d6
Merge branch 'main' into feat/safe-mode
DennisYu07 Jun 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 50 additions & 31 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export interface CliArgs {
appendSystemPrompt: string | undefined;
yolo: boolean | undefined;
bare: boolean | undefined;
safeMode?: boolean | undefined;
approvalMode: string | undefined;
telemetry: boolean | undefined;
telemetryTarget: string | undefined;
Expand Down Expand Up @@ -599,6 +600,11 @@ export async function parseArguments(): Promise<CliArgs> {
'Minimal mode: skip implicit startup auto-discovery and only honor explicitly provided CLI inputs.',
default: false,
})
.option('safe-mode', {
type: 'boolean',
description:
'Disable all customizations (context files, hooks, extensions, skills, MCP servers) for troubleshooting.',
})
.option('proxy', {
type: 'string',
description: 'Proxy for Qwen Code, like schema://user:password@host:port',
Expand Down Expand Up @@ -1361,6 +1367,11 @@ export async function loadCliConfig(
): Promise<Config> {
const debugMode = isDebugMode(argv);
const bareMode = isBareMode(argv.bare);
const safeMode =
argv.safeMode !== undefined
? argv.safeMode

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] safeMode is defined here and correctly applied to many settings (mcpServers, hooks, auto-memory, allowedHttpHookUrls), but several permission-related settings in this function still use only bareMode guards — or no guard at all. A project's .qwen/settings.json permission rules flow through to PermissionManager unchanged in safe mode.

Affected settings:

  • mergedAllow (line ~1549): bareMode ? [] : ... — no safeMode
  • mergedAsk (line ~1553): bareMode ? [] : ... — no safeMode
  • mergedDeny (line ~1556): bareMode ? [] : ... — no safeMode
  • disabledSlashCommands (line ~1575-1589): no guard at all
  • disabledTools (line ~1595): no guard at all
  • coreTools (line ~1839): bareMode ? undefined : ... — no safeMode
  • allowedTools (line ~1844): bareMode ? ... : ... — no safeMode
  • permissions.autoMode (line ~1855): no guard at all
  • approvalMode (line ~1467): !bareMode check — no safeMode

The most dangerous consequence: a malicious repo's "permissions": {"allow": ["Bash(*)"], "autoMode": "yolo"} auto-approves all shell commands even when the user runs --safe-mode expecting a stripped-down environment.

Suggested change
? argv.safeMode
const safeMode =
argv.safeMode !== undefined
? argv.safeMode
: process.env['QWEN_CODE_SAFE_MODE'] === 'true' ||
process.env['QWEN_CODE_SAFE_MODE'] === '1';
// NOTE: ensure all permission-related settings below use `bareMode || safeMode`
// guards — mergedAllow, mergedAsk, mergedDeny, autoMode, coreTools,
// allowedTools, disabledTools, disabledSlashCommands, approvalMode.

— qwen3.7-max via Qwen Code /review

: process.env['QWEN_CODE_SAFE_MODE'] === 'true' ||
process.env['QWEN_CODE_SAFE_MODE'] === '1';

// Set runtime output directory from settings (env var QWEN_RUNTIME_DIR
// is auto-detected inside getRuntimeBaseDir() at each call site).
Expand Down Expand Up @@ -1829,18 +1840,20 @@ export async function loadCliConfig(
currentSettings.setValue(settingScope, key, [...currentRules, rule]);
}
},
toolDiscoveryCommand: bareMode
? undefined
: settings.tools?.discoveryCommand,
toolCallCommand: bareMode ? undefined : settings.tools?.callCommand,
mcpServerCommand: bareMode ? undefined : settings.mcp?.serverCommand,
mcpServers: bareMode
? {}
: (() => {
const base = settings.mcpServers || {};
const cliMcpServers = parseMcpConfig(argv.mcpConfig);
return cliMcpServers ? { ...base, ...cliMcpServers } : base;
})(),
toolDiscoveryCommand:
bareMode || safeMode ? undefined : settings.tools?.discoveryCommand,
toolCallCommand:
bareMode || safeMode ? undefined : settings.tools?.callCommand,
mcpServerCommand:
bareMode || safeMode ? undefined : settings.mcp?.serverCommand,
mcpServers:
bareMode || safeMode
? {}
: (() => {
const base = settings.mcpServers || {};
const cliMcpServers = parseMcpConfig(argv.mcpConfig);
return cliMcpServers ? { ...base, ...cliMcpServers } : base;
})(),
allowedMcpServers: allowedMcpServers
? Array.from(allowedMcpServers)
: undefined,
Expand Down Expand Up @@ -1891,9 +1904,11 @@ export async function loadCliConfig(
generationConfig: resolvedCliConfig.generationConfig,
warnings: resolvedCliConfig.warnings,
bareMode,
allowedHttpHookUrls: bareMode
? []
: (settings.security?.allowedHttpHookUrls ?? []),
safeMode,
allowedHttpHookUrls:
bareMode || safeMode
? []
: (settings.security?.allowedHttpHookUrls ?? []),
cliVersion: await getCliVersion(),
ideMode,
chatCompression: settings.model?.chatCompression,
Expand All @@ -1914,24 +1929,28 @@ export async function loadCliConfig(
output: {
format: outputSettingsFormat,
},
enableManagedAutoMemory: bareMode
? false
: (settings.memory?.enableManagedAutoMemory ?? true),
enableManagedAutoDream: bareMode
? false
: (settings.memory?.enableManagedAutoDream ?? true),
enableAutoSkill: bareMode
? false
: (settings.memory?.enableAutoSkill ?? true),
enableManagedAutoMemory:
bareMode || safeMode
? false
: (settings.memory?.enableManagedAutoMemory ?? true),
enableManagedAutoDream:
bareMode || safeMode
? false
: (settings.memory?.enableManagedAutoDream ?? true),
enableAutoSkill:
bareMode || safeMode ? false : (settings.memory?.enableAutoSkill ?? true),
fastModel: settings.fastModel || undefined,
// Use separated hooks if provided, otherwise fall back to merged hooks
userHooks: bareMode
? undefined
: (hooksConfig?.userHooks ?? settings.hooks),
projectHooks: bareMode ? undefined : hooksConfig?.projectHooks,
hooks: bareMode ? undefined : settings.hooks, // Keep for backward compatibility
disableAllHooks: bareMode ? true : (settings.disableAllHooks ?? false),
stopHookBlockingCap: bareMode ? undefined : settings.stopHookBlockingCap,
userHooks:
bareMode || safeMode
? undefined
: (hooksConfig?.userHooks ?? settings.hooks),
projectHooks: bareMode || safeMode ? undefined : hooksConfig?.projectHooks,
hooks: bareMode || safeMode ? undefined : settings.hooks,
disableAllHooks:
bareMode || safeMode ? true : (settings.disableAllHooks ?? false),
stopHookBlockingCap:
bareMode || safeMode ? undefined : settings.stopHookBlockingCap,
channel: argv.channel,
// CLI flag wins over settings.json. `--json-fd` is fd-only (no settings
// equivalent — fd passing is a spawn-time concern). `--json-file` and
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/gemini.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ vi.mock('./config/config.js', () => ({
getQuestion: vi.fn(() => ''),
isInteractive: () => false,
getWarnings: vi.fn(() => []),
isSafeMode: vi.fn(() => false),
getModelsConfig: vi.fn(() => ({ getCurrentAuthType: () => null })),
} as unknown as Config),
parseArguments: vi.fn().mockResolvedValue({}),
Expand Down Expand Up @@ -200,6 +201,7 @@ describe('gemini.tsx main function', () => {
getProjectRoot: () => '/',
getOutputFormat: () => OutputFormat.TEXT,
getWarnings: () => [],
isSafeMode: () => false,
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getSessionId: () => 'test-session-id',
} as unknown as Config;
Expand Down Expand Up @@ -326,6 +328,7 @@ describe('gemini.tsx main function', () => {
getProjectRoot: () => '/',
getOutputFormat: () => OutputFormat.TEXT,
getWarnings: () => [],
isSafeMode: () => false,
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getSessionId: () => 'test-session-id',
} as unknown as Config;
Expand Down Expand Up @@ -638,6 +641,7 @@ describe('gemini.tsx main function', () => {
getInputFormat: () => 'stream-json',
getContentGeneratorConfig: () => ({ authType: 'test-auth' }),
getWarnings: () => [],
isSafeMode: () => false,
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getUsageStatisticsEnabled: () => true,
getSessionId: () => 'test-session-id',
Expand Down Expand Up @@ -768,6 +772,7 @@ describe('gemini.tsx main function kitty protocol', () => {
getScreenReader: () => false,
getGeminiMdFileCount: () => 0,
getWarnings: () => [],
isSafeMode: () => false,
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getUsageStatisticsEnabled: () => true,
getSessionId: () => 'test-session-id',
Expand Down Expand Up @@ -878,6 +883,7 @@ describe('gemini.tsx main function kitty protocol', () => {
getScreenReader: () => false,
getGeminiMdFileCount: () => 0,
getWarnings: () => [],
isSafeMode: () => false,
getModelsConfig: () => ({
getCurrentAuthType: () => null,
getGenerationConfig: () => ({}),
Expand Down Expand Up @@ -964,6 +970,7 @@ describe('gemini.tsx main function kitty protocol', () => {
getScreenReader: () => false,
getGeminiMdFileCount: () => 0,
getWarnings: () => [],
isSafeMode: () => false,
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getUsageStatisticsEnabled: () => true,
getSessionId: () => 'test-session-id',
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/gemini.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -935,6 +935,11 @@ export async function main() {
let input = config.getQuestion();
const startupWarnings = [
...new Set([
...(config.isSafeMode()
? [
'⚠ SAFE MODE — all customizations disabled (hooks, extensions, skills, MCP servers, QWEN.md). Restart without --safe-mode to resume normal operation.',
]
: []),
...(await getStartupWarnings()),
...(await getUserStartupWarnings({
workspaceRoot: process.cwd(),
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/serve/workspaceAgents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1289,6 +1289,7 @@ export function createDaemonSubagentManager(
getSdkMode: () => false,
getProjectRoot: () => boundWorkspace,
getActiveExtensions: () => [],
isSafeMode: () => false,
} as unknown as Record<string | symbol, unknown>;
const guarded = new Proxy(stub, {
get(target, prop) {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/components/Footer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const createMockConfig = (overrides = {}) => ({
getProjectRoot: vi.fn(() => '/test/project'),
getSessionId: vi.fn(() => 'test-session'),
getMemoryManager: vi.fn(createMockMemoryManager),
isSafeMode: vi.fn(() => false),
...overrides,
});

Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/ui/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@ export const Footer: React.FC = () => {
node: <Text color={theme.status.success}>🔒 {sandboxInfo}</Text>,
});
}
if (config.isSafeMode()) {
rightItems.push({
key: 'safe-mode',
node: <Text color={theme.status.warning}>⚠ Safe Mode</Text>,
});
}
if (debugMode) {
rightItems.push({
key: 'debug',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/components/MemoryDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ describe('MemoryDialog', () => {
getWorkingDir: vi.fn(() => '/tmp/project'),
getProjectRoot: vi.fn(() => '/tmp/project'),
getBareMode: vi.fn(() => false),
isSafeMode: vi.fn(() => false),
// Stale snapshot getters — the dialog must NOT read its toggle state
// from these; it reads from the live merged settings instead.
getManagedAutoMemoryEnabled: vi.fn(() => false),
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/ui/components/MemoryDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,9 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) {
// the Config snapshot: Config is frozen at startup and never reflects a
// setValue() write, so reopening the dialog would otherwise show stale state.
const bareMode = config.getBareMode();
const safeMode = config.isSafeMode();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] readToggle correctly forces the initial display state to false in safe mode, but the three toggle handlers (handleToggleAutoMemory, handleToggleAutoDream, handleToggleAutoSkill) still call loadedSettings.setValue() unconditionally. A user in safe mode can toggle these on — the write persists to settings.json and silently takes effect on the next normal launch, without the user having consciously opted in during a normal session.

Consider disabling the toggle handlers (or showing a warning) when safeMode is true:

Suggested change
const safeMode = config.isSafeMode();
const readToggle = (value: boolean | undefined): boolean =>
!bareMode && !safeMode && (value ?? true);
// Also guard handlers: if (safeMode) return; at the top of each handleToggle*

— qwen3.7-max via Qwen Code /review

const readToggle = (value: boolean | undefined): boolean =>
!bareMode && (value ?? true);
!bareMode && !safeMode && (value ?? true);
const [autoMemoryOn, setAutoMemoryOn] = useState(() =>
readToggle(loadedSettings.merged.memory?.enableManagedAutoMemory),
);
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ vi.mock('node:process', () => {
exit: mockProcessExit,
platform: 'sunos',
cwd: () => '/fake/dir',
env: {},
} as unknown as NodeJS.Process;
return {
...mockProcess,
Expand Down
Loading
Loading