From 787f2a939094eb9b17f6d0eb71a3bf6ed1fb40a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=AE=AE=E0=AE=A9=E0=AF=8B=E0=AE=9C=E0=AF=8D=E0=AE=95?= =?UTF-8?q?=E0=AF=81=E0=AE=AE=E0=AE=BE=E0=AE=B0=E0=AF=8D=20=E0=AE=AA?= =?UTF-8?q?=E0=AE=B4=E0=AE=A9=E0=AE=BF=E0=AE=9A=E0=AF=8D=E0=AE=9A=E0=AE=BE?= =?UTF-8?q?=E0=AE=AE=E0=AE=BF?= Date: Wed, 19 Aug 2026 11:22:52 +0530 Subject: [PATCH] refactor(cli): commander-authoritative argv handling for SSH / cc:// and remote bypass (recovery of #1939) (#2098) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): mirror programmatic args onto process.argv (hardened) Address CodeRabbit's Critical finding: cliMain() parses process.argv directly, so a programmatic main(args) call must reflect args there or it silently runs the host's argv. Restores the args->argv sync but hardened against the three bugs the multi-agent review confirmed in the earlier scoped/serialized version: - no restore (was F3: a finally-restore flipped argv out from under the SIGINT handler and bypass-safety notice while the session was live) - length-guarded exec/script slots (was F4: <2 host argv entries, e.g. node -e, dropped the flag past commander's argv.slice(2)) - no serialization chain (was F2: overlapping calls hung indefinitely) For the normal binary launch (args defaulted from process.argv.slice(2)) the assignment is a value-identical no-op. Tests: programmatic args reach cliMain, argv is not restored, and the exec/script slots are padded under a short host argv. Verified: 48 entrypoint+safety tests, tsc, build, and e2e re-probes of all former --yolo bug scenarios on the built binary. Co-Authored-By: Claude Fable 5 * fix(cli): ssh strip-all dangerous-skip tokens; docs+tests for native alias Address the latest Copilot + CodeRabbit findings on the native-alias PR: - ssh argv pre-scan removed only the first dangerous-skip token, so `ssh --yolo --dangerously-skip-permissions host` (or a repeat) left a survivor that re-enabled bypass after the ssh rewrite. Strip every matching token. (connect already used the filter helper.) [Copilot] - web/src/data/cliFlags.ts: list the flag as '--yolo, --dangerously-skip-permissions' to match the commander registration, not just the description. [Copilot] - Replace the source-string-count registration test with (a) a behavioral test that the built CLI lists the alias in --help — proving the main-command registration is live, not dead code or the wrong command — and (b) a structured .option() assertion plus a check that the ssh pre-scan handles the alias (ssh --help renders root help, so the ssh --yolo path is the pre-scan, not the commander option). [CodeRabbit] - PR description rewritten to describe the native alias instead of the removed argv rewrite. [Copilot] Verified: 23 cli + 10 safety tests, tsc, build, e2e (--yolo --help lists the alias; ssh --help; mcp add --yolo names the typed flag). Co-Authored-By: Claude Fable 5 * test(cli): runtime coverage for dangerous-skip strip; share one helper CodeRabbit (approved, follow-up): the strip-all fix was only source- checked, so the strip loop could regress unnoticed. Extract the argv scanners into src/utils/dangerousSkipFlags.ts (isDangerousSkipFlag / hasDangerousSkipFlag / stripDangerousSkipFlags) and unit-test them at runtime: both spellings detected, every token stripped (canonical + --yolo + repeats), input not mutated. Both the direct-connect and ssh rewrites in main.tsx now use the shared stripDangerousSkipFlags — the ssh path's bespoke single-splice while loop (the original survivor bug) is gone, replaced by an in-place splice(0, len, ...strip). One tested code path instead of two. Verified: helper + 23 cli + 10 safety tests, tsc, build, e2e. Co-Authored-By: Claude Fable 5 * style(cli): semicolon + strip both spellings in safety-test reset Copilot review nits: - statusNoticeDefinitions.tsx: add the trailing semicolon to the parenthesized return to match the file's semicolon style. - safety.test.tsx: the beforeEach argv reset filtered only --dangerously-skip-permissions; strip --yolo too so the 'without the flag' cases can't go order-dependent if the runner is invoked with --yolo in argv. - PR description re-synced to the native-alias approach (the earlier edit had reverted to the old 'normalize via argv rewrite' wording). Co-Authored-By: Claude Fable 5 * fix(cli): notice names --yolo; move import out of boot-critical block Both bots on 50772d28: - The bypass-safety notice fires for --yolo but its text named only '--dangerously-skip-permissions is active'. Reword to '... (alias --yolo) is active' so the message matches what the user typed. Add a rendered-notice regression assertion for the --yolo case. [CodeRabbit + Copilot] - Move the dangerousSkipFlags import out of the boot-critical header block (it ran before profileCheckpoint('main_tsx_entry'), adding pre-checkpoint work in the order-preserving bundle) down to the regular internal-import group. [Copilot] Verified: 39 cli+safety+helper tests, tsc, build, e2e. Co-Authored-By: Claude Fable 5 * fix(cli): honor -- end-of-options in the raw-argv dangerous-skip scanners Copilot (3x on 5cd0e304): the pre-commander scanners matched --yolo / --dangerously-skip-permissions anywhere in argv, so a positional after the -- marker (openclaude -p -- --yolo, cc://… -- --yolo, ssh host -- --yolo) was misread as the bypass flag — enabling bypass / stripping the token / false-firing the safety notice, even though commander treats it as positional. Pre-existing for the canonical flag, but the short alias makes it far likelier. Make the shared helpers --aware in one place: hasDangerousSkipFlag and stripDangerousSkipFlags only consider option-position tokens (before the first --) and preserve everything from -- onward. Route the safety notice's hasDangerouslySkipPermissionsArg through the same helper. Regression tests: helper ignores/preserves post-- tokens; the notice does not fire for -p -- --yolo. Verified: 41 helper+safety+cli tests, tsc, build, e2e. Co-Authored-By: Claude Fable 5 * refactor(cli): don't leak args via process.argv; stop simulating commander in the scanner Address jatmn's two P1 findings, both by removing a band-aid rather than adding another: - P1a: main() no longer mirrors its args onto the process-global process.argv. Verified there is no production or SDK caller that passes custom args (the sole entry is the no-arg auto-run), so the mirror only risked leaking a programmatic invocation's args (including a bypass flag) into an overlapping call or the host process. Back to the baseline signature; cliMain parses the real process.argv. - P1b: revert the end-of-options ("--") handling in the dangerous-skip scanner. As jatmn notes, correctly classifying "--yolo" (it can be a required option value like "--system-prompt --yolo", or follow a "--" consumed as a variadic value) requires commander's option-arity state machine, the exact simulation this feature was reworked to delete. The scanner now mirrors the canonical --dangerously-skip-permissions presence check exactly: both spellings behave identically, and the approximation is documented as a pre-existing limitation of pre-commander scanning. Net: every difference between --yolo and the canonical flag is now either native-commander-correct (the registration) or an identical approximation (the raw scanners). No new argv mutation, no parser simulation. Verified: 38 cli+helper+safety tests, tsc, build, e2e. Co-Authored-By: Claude Fable 5 * docs(cli): clarify main() argv comment — skills/--update do rewrite argv Copilot: the "does not mirror args onto process.argv" comment was misleading — the skills and --update fast-paths reassign process.argv to re-route to their subcommand. Note the exception; the no-mirror rule is about not injecting the caller's args into the general cliMain flow. Co-Authored-By: Claude Fable 5 * fix(setup): name the --yolo alias in the root/sudo bypass error Copilot: the root/sudo safety error printed only --dangerously-skip-permissions; a user who typed --yolo saw a flag they didn't use. Mention both spellings, matching the ssh help and cliFlags. Co-Authored-By: Claude Fable 5 * test(cli): import Command from @commander-js/extra-typings, matching prod Copilot: the alias behavioral test built its probe Command from 'commander', but production registers options via @commander-js/extra-typings. Use the same package so the test exercises the exact parser prod uses. Co-Authored-By: Claude Fable 5 * fix(ssh): don't treat an option value as the permission-bypass alias jatmn P1: `ssh host --permission-mode --yolo` stripped --yolo as a bypass flag (enabling bypass) and left --permission-mode valueless, whereas commander parses --yolo as the (invalid) mode value and rejects it — a silent privilege escalation. Same class affected --model / --resume / --fallback-model. Reorder the ssh pre-parser so value-taking flags consume their value — including a dangerous-skip token in the value slot — BEFORE the dangerous-skip strip runs. Extract the whole flag pre-parse into a pure, unit-tested helper (parseSshFlags) so the security-sensitive arity handling has regression coverage: escalation guards for --permission-mode/--model + a value of --yolo, plus genuine standalone --yolo still enabling bypass. Verified: 6 ssh + dangerousSkipFlags + cli tests, tsc, build, e2e. Co-Authored-By: Claude Fable 5 * test(cli): update ssh-path source assertion for parseSshFlags extraction Co-Authored-By: Claude Fable 5 * chore(cli): revert unrelated --bare doc; drop stray blank line CodeRabbit/Copilot on the rebased branch: - Revert the --bare help rewrite (background prefetches / CLAUDE_CODE_SIMPLE / expanded flag list) in main.tsx and web/src/data/cliFlags.ts — it is unrelated to the --yolo alias and broadens scope. cliFlags.ts now changes only the --yolo alias line. - Remove the stray double blank line before cliMain. Skipped CodeRabbit's "alias order breaks property naming" (Major): verified against @commander-js/extra-typings that '--yolo, --dangerously-skip- permissions' maps BOTH spellings to opts().dangerouslySkipPermissions (commander keys off the last long flag); opts().yolo is undefined. Bypass is not broken. Co-Authored-By: Claude Fable 5 * fix(ssh): honor the -- end-of-options marker in the ssh pre-parser CodeRabbit Critical + jatmn P1 ("respect option arity/end-of-options semantics"): `ssh host -- --yolo` (or `-- --local`, `-- --permission-mode x`) parsed the post-`--` tokens as flags, so a positional --yolo escalated to bypass. parseSshFlags now parses only the prefix before `--` and keeps everything from `--` on positional. This is unambiguous here because the ssh subcommand registers no variadic options that could consume `--` as a value. The connect (cc://) path is deliberately left plain and documented: it rewrites to the main command, which HAS variadic options (--add-dir …) that commander lets consume `--` as a value, so a naive `--` split there would be the incomplete simulation flagged in P1b. That false-positive is pre-existing for the canonical flag. Skipped (both pre-existing, ported verbatim / out of scope): extractFlag mixed `--flag=x --flag y` precedence, and the setup.ts root/sudo message not naming --allow-dangerously-skip-permissions. Verified: 8 ssh + cli tests, tsc, build. Co-Authored-By: Claude Fable 5 * refactor: make --yolo/bypass Commander-authoritative on cc:// connect + notice * fixup! address CodeRabbit/Copilot review findings on #2098 - Restore parseMaxTurnsCommanderArgument for --max-turns validation. - Generalize root/sudo error message for all bypass modes/flags. - Clarify cli.tsx comment about main() not leaking args into process.argv. - Detect -p/--print in all commander-accepted forms (including --print= and -p). - Correct dangerousSkipFlags.ts doc: SSH strips, cc:// preserves for Commander. - Extend live help test to ssh and open subcommands. * fixup! extract hasPrintFlag helper + regression tests - Move print-flag detection to src/utils/printFlag.ts so it is unit-testable. - Add regression tests for -p, --print, --print=prompt, -pprompt, and -- separator. - Import the helper in main.tsx and drop the local copy. * fixup! use hasPrintFlag in every startup print-mode check - Replace exact-string includes in the SSH headless rejection and the main print-mode gate with the shared hasPrintFlag predicate. - Add source assertions confirming cc:// rewrite, SSH rejection, and main print-mode gate all use the same helper. * fixup! align dangerously-skip notice comment with commander-authoritative mode - Remove stale 'reads from process.argv' text; the notice now keys off the resolved permissionMode. * fixup! add SIGINT handler to hasPrintFlag source assertions - Include the SIGINT print-mode gate in the source-level consistency check. * fixup! restore maxTurns forwarding dropped during rebase - Re-add options.maxTurns to sessionConfig and the interactive REPL props for direct-connect, SSH, remote viewer, and remote creation paths (matches main). * fixup! address Copilot suppressed comments on #2098 - parseSshFlags now consumes required-arg values unconditionally, matching commander and preventing flag-like values from leaking into later guards. - Drop the now-unused isDangerousSkipFlag import from sshPreParse.ts. - Make the dangerously-skip notice text mode-agnostic so settings-driven bypassPermissions is not mislabeled as a CLI flag. * fixup: left-to-right SSH parse and fullAccess sandbox warning - Rewrite parseSshFlags as a single left-to-right arity-aware scan. Value-taking flags now consume every occurrence (including equals forms) and always consume the next token as their value, even if it resembles a flag (e.g. --permission-mode --local or --model --yolo value). - Cover fullAccess with the dangerously-skip-permissions sandbox warning and add focused regression tests for bypassPermissions/fullAccess rendering. Co-Authored-By: Claude Opus 5 * fixup: preserve missing SSH option values and embedded equals - Keep value-taking SSH flags in remaining when they have no available value (last token before -- or trailing), letting commander report the missing required argument. - Use slice after the prefix for equals-form values so embedded '=' characters are preserved (e.g. --model=provider=model). - Add regression tests for last-token, before--, and embedded-equals cases. Co-Authored-By: Claude Opus 5 * fixup: arity-aware print detection and optional --resume for SSH - hasPrintFlag now skips tokens consumed as values by preceding value-taking options (required, optional, and variadic), so --system-prompt --print=custom is no longer misclassified as print mode. - parseSshFlags treats --resume as an optional-value option: a bare --resume is forwarded, a non-option value is consumed, and following flags (e.g. --yolo) remain available for their own parsing. - Added focused regression tests for both fixes. Co-Authored-By: Claude Opus 5 * fixup: handle inline values and -- in hasPrintFlag - Do not advance past a following flag when a required/optional/variadic option already provided its value inline via =. - Stop the scan when a value-taking option is immediately followed by --. - Added regression tests for --model=foo --print and --model -- --print. Co-Authored-By: Claude Opus 5 * fixup! address jatmn review: arity-aware print/SSH parsing + fullAccess notice Co-Authored-By: Claude Opus 5 * fixup! correct bypassPermissions notice wording Co-Authored-By: Claude Opus 5 * Address remaining CodeRabbit findings on #2098 - Sync hasPrintFlag with all root-command required/variadic/optional options, including hidden/feature-gated flags (--agent-id, --sdk-url, --channels, etc.). - Treat the SDK 'full-access' spelling as fullAccess in the dangerous-skip-permissions status notice so the stronger warning is shown. Co-Authored-By: Claude Opus 5 * fix(cli): make hasPrintFlag the single pre-Commander print classifier - Replace all startup .includes('-p')/.includes('--print') checks with the arity-aware hasPrintFlag() predicate so value-consumed tokens are not misclassified as print mode. - Centralize SSH headless detection in sshArgvImpliesHeadless(), covering both tail argv after host/cwd and flags forwarded via extraCliArgs (e.g. --resume=--print). - Soften the fullAccess status-notice wording to match runtime behavior: most consent checks are bypassed, but hard deny rules and user-interaction prompts still apply. - Add/extend tests for interactivity, SSH flag pre-parsing, status notices, and a regression check that prevents naive print-token checks from re-entering. Co-Authored-By: Claude Opus 5 * fix(ssh): preserve --resume= values and run headless guard before host extraction - Keep the inline value attached when forwarding --resume=... extraCliArgs so optional-value semantics are preserved (e.g. --resume=--print resumes a conversation named "--print", it does not enable print mode). - Move the SSH headless guard before host/cwd extraction in main.tsx so print flags that appear before the host are rejected. - Update tests: --resume=--print is no longer headless, required-value options like --model -p remain non-headless, and add coverage for print flags before the host. Co-Authored-By: Claude Opus 5 * fix(cli): remove repeatable single-value options from VARIADIC_OPTIONS --plugin-dir and --provider-env-file are registered as repeatable single-value options, not variadic. Keeping them in VARIADIC_OPTIONS made the arity model wrong and could consume extra tokens if check order changed. They are already covered by REQUIRED_VALUE_OPTIONS. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Fable 5 --- src/cli/bg.ts | 4 +- src/entrypoints/cli.test.ts | 360 ++++++++++-------- src/entrypoints/cli.tsx | 2 +- src/main.tsx | 141 +++---- src/services/api/logging.ts | 6 +- src/setup.ts | 2 +- src/utils/dangerousSkipFlags.test.ts | 51 +++ src/utils/dangerousSkipFlags.ts | 40 ++ src/utils/earlyInput.ts | 4 +- src/utils/gracefulShutdown.ts | 3 +- src/utils/interactivity.test.ts | 31 ++ src/utils/interactivity.ts | 6 +- src/utils/printFlag.test.ts | 93 +++++ src/utils/printFlag.ts | 151 ++++++++ src/utils/providerValidation.ts | 4 +- src/utils/sshPreParse.test.ts | 193 ++++++++++ src/utils/sshPreParse.ts | 174 +++++++++ .../statusNoticeDefinitions.safety.test.tsx | 89 ++++- src/utils/statusNoticeDefinitions.tsx | 66 ++-- 19 files changed, 1130 insertions(+), 290 deletions(-) create mode 100644 src/utils/dangerousSkipFlags.test.ts create mode 100644 src/utils/dangerousSkipFlags.ts create mode 100644 src/utils/printFlag.test.ts create mode 100644 src/utils/printFlag.ts create mode 100644 src/utils/sshPreParse.test.ts create mode 100644 src/utils/sshPreParse.ts diff --git a/src/cli/bg.ts b/src/cli/bg.ts index 3ad2da90b..877e981b1 100644 --- a/src/cli/bg.ts +++ b/src/cli/bg.ts @@ -5,6 +5,7 @@ import { open, unlink } from 'node:fs/promises' import { basename } from 'node:path' import treeKill from 'tree-kill' import { argsBeforeDelimiter } from '../utils/cliArgs.js' +import { hasPrintFlag } from '../utils/printFlag.js' import { isProcessRunning } from '../utils/genericProcessUtils.js' import { assertBackgroundSessionNameAvailable, @@ -335,8 +336,7 @@ function findSessionName(args: string[]): string | undefined { } function hasPrintMode(args: string[]): boolean { - const searchable = argsBeforeDelimiter(args) - return searchable.includes('--print') || searchable.includes('-p') + return hasPrintFlag(args) } function insertBeforePrompt(args: string[], values: string[]): string[] { diff --git a/src/entrypoints/cli.test.ts b/src/entrypoints/cli.test.ts index 284e95f27..34d00b61b 100644 --- a/src/entrypoints/cli.test.ts +++ b/src/entrypoints/cli.test.ts @@ -16,6 +16,7 @@ import { import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { Command } from '@commander-js/extra-typings' import { BACKGROUND_SESSION_ID_ENV, BACKGROUND_SESSION_LAUNCHER_PID_ENV, @@ -326,74 +327,76 @@ describe('cli.tsx — --provider startup ordering', () => { }) +const mockImporters = { + startupProfiler: async () => ({ + profileCheckpoint: mockProfileCheckpoint, + }), + bg: async () => ({ + psHandler: mockPsHandler, + logsHandler: mockLogsHandler, + attachHandler: mockAttachHandler, + killHandler: mockKillHandler, + handleBgFlag: mockHandleBgFlag, + }), + bgFinalizer: async () => ({ + prepareBackgroundSessionFinalizer: mockPrepareBackgroundSessionFinalizer, + }), + envFile: async () => ({ + loadEnvFile: mockLoadEnvFile, + parseProviderEnvFileArgs: mockParseProviderEnvFileArgs, + reapplyRememberedEnvFileValues: mockReapplyRememberedEnvFileValues, + rememberLoadedEnvFileValues: mockRememberLoadedEnvFileValues, + }), + config: async () => ({ + enableConfigs: mockEnableConfigs, + }), + managedEnv: async () => ({ + applySafeConfigEnvironmentVariables: + mockApplySafeConfigEnvironmentVariables, + }), + providerProfile: async () => ({ + applyStartupEnvFromProfile: mockApplyStartupEnvFromProfile, + }), + providerValidation: async () => ({ + getProviderValidationError: mockGetProviderValidationError, + validateProviderEnvForStartupOrExit: + mockValidateProviderEnvForStartupOrExit, + }), + flagSettings: async () => ({ + eagerLoadSettingsFromArgs: mockEagerLoadSettingsFromArgs, + }), + agentRouting: async () => ({ + applyAgentProviderOverrideToEnv: mockApplyAgentProviderOverrideToEnv, + resolveOutOfProcessTeammateProviderFromCliArgs: + mockResolveOutOfProcessTeammateProviderFromCliArgs, + }), + settings: async () => ({ + getInitialSettings: mockGetInitialSettings, + }), + githubModelsCredentials: async () => ({ + hydrateGithubModelsTokenFromSecureStorage: + mockHydrateGithubModelsTokenFromSecureStorage, + refreshGithubModelsTokenIfNeeded: mockRefreshGithubModelsTokenIfNeeded, + }), + startupScreen: async () => ({ + printStartupScreen: mockPrintStartupScreen, + }), + earlyInput: async () => ({ + startCapturingEarlyInput: mockStartCapturingEarlyInput, + }), + main: async () => ({ + main: mockCliMain, + }), +} + describe('cli.tsx — background routing behavior', () => { const bgOptions = { bgSessionsEnabled: true, - importers: { - startupProfiler: async () => ({ - profileCheckpoint: mockProfileCheckpoint, - }), - bg: async () => ({ - psHandler: mockPsHandler, - logsHandler: mockLogsHandler, - attachHandler: mockAttachHandler, - killHandler: mockKillHandler, - handleBgFlag: mockHandleBgFlag, - }), - bgFinalizer: async () => ({ - prepareBackgroundSessionFinalizer: - mockPrepareBackgroundSessionFinalizer, - }), - envFile: async () => ({ - loadEnvFile: mockLoadEnvFile, - parseProviderEnvFileArgs: mockParseProviderEnvFileArgs, - reapplyRememberedEnvFileValues: mockReapplyRememberedEnvFileValues, - rememberLoadedEnvFileValues: mockRememberLoadedEnvFileValues, - }), - config: async () => ({ - enableConfigs: mockEnableConfigs, - }), - managedEnv: async () => ({ - applySafeConfigEnvironmentVariables: - mockApplySafeConfigEnvironmentVariables, - }), - providerProfile: async () => ({ - applyStartupEnvFromProfile: mockApplyStartupEnvFromProfile, - }), - providerValidation: async () => ({ - getProviderValidationError: mockGetProviderValidationError, - validateProviderEnvForStartupOrExit: - mockValidateProviderEnvForStartupOrExit, - }), - flagSettings: async () => ({ - eagerLoadSettingsFromArgs: mockEagerLoadSettingsFromArgs, - }), - agentRouting: async () => ({ - applyAgentProviderOverrideToEnv: mockApplyAgentProviderOverrideToEnv, - resolveOutOfProcessTeammateProviderFromCliArgs: - mockResolveOutOfProcessTeammateProviderFromCliArgs, - }), - settings: async () => ({ - getInitialSettings: mockGetInitialSettings, - }), - githubModelsCredentials: async () => ({ - hydrateGithubModelsTokenFromSecureStorage: - mockHydrateGithubModelsTokenFromSecureStorage, - refreshGithubModelsTokenIfNeeded: mockRefreshGithubModelsTokenIfNeeded, - }), - startupScreen: async () => ({ - printStartupScreen: mockPrintStartupScreen, - }), - earlyInput: async () => ({ - startCapturingEarlyInput: mockStartCapturingEarlyInput, - }), - main: async () => ({ - main: mockCliMain, - }), - }, + importers: mockImporters, } as unknown as Parameters[1] const originalAutoRunGuard = process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN + const savedArgv = [...process.argv] beforeAll(async () => { process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN = '1' @@ -415,6 +418,10 @@ describe('cli.tsx — background routing behavior', () => { clearRuntimeMocks() }) + afterEach(() => { + process.argv = [...savedArgv] + }) + it('dispatches background management commands before startup work', async () => { const cases: Array<[string, typeof mockPsHandler, string[]]> = [ ['ps', mockPsHandler, ['--json']], @@ -605,99 +612,148 @@ describe('Node 24 premature exit regression (issue #1678)', () => { expect(src).toMatch(/await main\(\)/) expect(src).not.toMatch(/^\s*void main\(\)/m) }) +}) - describe('--yolo alias', () => { - it('is registered on the main command next to the canonical flag', async () => { - const src = await Bun.file(`${import.meta.dir}/../main.tsx`).text() - expect(src).toContain( - ".option('--yolo, --dangerously-skip-permissions', 'Bypass all permission checks", - ) - }) +describe('cli.tsx — --yolo alias (PR #1939)', () => { + const options = { + importers: mockImporters, + } as unknown as Parameters[1] + const originalAutoRunGuard = + process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN + const savedArgv = [...process.argv] - it('is registered on the ssh stub command', async () => { - const src = await Bun.file(`${import.meta.dir}/../main.tsx`).text() - const sshCmd = src.indexOf("program.command('ssh [dir]')") - expect(sshCmd).toBeGreaterThanOrEqual(0) - const sshAction = src.indexOf('.action(async () => {', sshCmd) - const sshBlock = src.slice(sshCmd, sshAction) - expect(sshBlock).toContain( - "--yolo, --dangerously-skip-permissions", - ) - }) + beforeAll(async () => { + process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN = '1' + const entrypoint = await import('./cli.js') + runCliEntrypoint = entrypoint.main + }) - it('is recognized by the cc:// and ssh raw-argv scans', async () => { - const src = await Bun.file(`${import.meta.dir}/../main.tsx`).text() - // cc:// sets remote state via includes(); the rewrites and ssh path strip - // both spellings from the forwarded argv. - expect(src).toContain( - "rawCliArgs.includes('--dangerously-skip-permissions') || rawCliArgs.includes('--yolo')", - ) - expect(src).toContain("arg !== '--dangerously-skip-permissions' && arg !== '--yolo'") - expect(src).toContain( - "if (arg === '--dangerously-skip-permissions' || arg === '--yolo')", - ) - }) - - it('strips both bypass spellings from cc:// and ssh forwarded argv', async () => { - const src = await Bun.file(`${import.meta.dir}/../main.tsx`).text() - // Passing both flags at once must not leave one behind as an unknown - // option on the headless `open` subcommand or in the ssh forwarded line. - const ccBlockStart = src.indexOf('Check for cc:// or cc+unix:// URL in argv') - const ccBlockEnd = src.indexOf('// Handle deep link URIs early', ccBlockStart) - const ccBlock = src.slice(ccBlockStart, ccBlockEnd) - const ccOccurrences = - ccBlock.split("'--dangerously-skip-permissions'").length - 1 + - ccBlock.split("'--yolo'").length - 1 - expect(ccOccurrences).toBeGreaterThanOrEqual(4) - - const sshBlockStart = src.indexOf("if (rawCliArgs[0] === 'ssh')") - const sshBlockEnd = src.indexOf('// else: `claude ssh` with no host', sshBlockStart) - const sshBlock = src.slice(sshBlockStart, sshBlockEnd) - expect(sshBlock).toContain( - "if (arg === '--dangerously-skip-permissions' || arg === '--yolo')", - ) - }) - - it('is recognized by the skills leading scan so --yolo skills list routes', async () => { - const src = await Bun.file(`${import.meta.dir}/cli.tsx`).text() - const setStart = src.indexOf('SKILLS_LEADING_BOOLEAN_FLAGS = new Set([') - expect(setStart).toBeGreaterThanOrEqual(0) - const setEnd = src.indexOf(']', setStart) - const setBody = src.slice(setStart, setEnd) - expect(setBody).toContain("'--yolo'") - }) - - it('is recognized by the skills trailing scan so skills list --yolo routes', async () => { - const src = await Bun.file( - `${import.meta.dir}/../cli/handlers/skillsCli.ts`, - ).text() - const setStart = src.indexOf('TRAILING_GLOBAL_BOOLEAN_FLAGS = new Set([') - expect(setStart).toBeGreaterThanOrEqual(0) - const setEnd = src.indexOf(']', setStart) - const setBody = src.slice(setStart, setEnd) - expect(setBody).toContain("'--yolo'") - }) - - it('appears in the built CLI help', async () => { - const fs = await import('node:fs') - const path = await import('node:path') - const cliPath = path.resolve(import.meta.dir, '../../dist/cli.mjs') - expect(fs.existsSync(cliPath)).toBe(true) - - const originalGuard = process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN + afterAll(() => { + if (originalAutoRunGuard === undefined) { delete process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN - try { - const proc = Bun.spawn(['node', cliPath, '--help'], { stdout: 'pipe' }) - const text = await new Response(proc.stdout).text() - await proc.exited - expect(text).toContain('--yolo, --dangerously-skip-permissions') - } finally { - if (originalGuard === undefined) { - delete process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN - } else { - process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN = originalGuard - } - } - }) + } else { + process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN = + originalAutoRunGuard + } + }) + + beforeEach(() => { + clearRuntimeMocks() + }) + + afterEach(() => { + process.argv = [...savedArgv] + }) + + // Mirrors the registration in main.tsx. commander derives the option's + // attribute from the LAST long flag, so both spellings set the same + // dangerouslySkipPermissions key — the whole reason a native alias works. + const buildProgram = () => + new Command() + .option( + '--yolo, --dangerously-skip-permissions', + 'bypass', + () => true, + ) + .allowExcessArguments() + .exitOverride() + + it('commander resolves --yolo to dangerouslySkipPermissions', () => { + expect( + buildProgram().parse(['node', 'x', '--yolo']).opts() + .dangerouslySkipPermissions, + ).toBe(true) + expect( + buildProgram().parse(['node', 'x', '--dangerously-skip-permissions']).opts() + .dangerouslySkipPermissions, + ).toBe(true) + expect( + buildProgram().parse(['node', 'x']).opts().dangerouslySkipPermissions, + ).toBeUndefined() + }) + + it('passes args through to cliMain verbatim — no per-token --yolo rewrite', async () => { + // Regression guard for the six correctness bugs the old pre-parse argv + // rewrite caused: --yolo must reach commander untouched, whatever position + // it sits in (after a value flag, after `--`, or on a subcommand), so + // commander — not a hand-rolled scanner — resolves it. + const cases = [ + ['--yolo', '-p', 'hi'], + ['--system-prompt', '--yolo'], + ['-p', '--', '--yolo'], + ['mcp', 'add', '--yolo', 'srv', 'cmd'], + ] + for (const argv of cases) { + clearRuntimeMocks() + process.argv = ['node', 'openclaude', ...argv] + let argvSeenByCliMain: string[] | undefined + mockCliMain.mockImplementationOnce(async () => { + argvSeenByCliMain = [...process.argv] + }) + + await runCliEntrypoint(argv, options) + + expect(argvSeenByCliMain).toEqual(['node', 'openclaude', ...argv]) + } + }) + + it('does not mutate the host process.argv (no leak of a caller args array)', async () => { + // main() must not push an explicit args array into the process-global argv: + // cliMain reads the real process.argv, and leaking a caller's args (e.g. a + // bypass flag) into it would corrupt an overlapping call or the host. + const hostArgv = ['node', 'openclaude', 'host-arg'] + process.argv = [...hostArgv] + await runCliEntrypoint(['--yolo', '-p', 'hi'], options) + expect(process.argv).toEqual(hostArgv) + }) + + it('the built CLI lists --yolo on the main, ssh, and open command help (live registration)', async () => { + // Behavioral proof the alias is registered on the real commands — not dead + // code or the wrong command: commander only prints an option in --help if it + // is actually registered. --help short-circuits before any startup. + const fs = await import('node:fs') + const path = await import('node:path') + const cliPath = path.resolve(import.meta.dir, '../../dist/cli.mjs') + if (!fs.existsSync(cliPath)) return // needs `bun run build`; always present in CI + // The describe's beforeAll sets OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN=1 + // to keep main() from auto-running in-process; the child must NOT inherit it + // or the entrypoint never runs and prints nothing. + const childEnv: Record = { + ...process.env, + OPENCLAUDE_DISABLE_TELEMETRY: '1', + } + delete childEnv.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN + for (const argv of [ + ['--yolo', '--help'], + ['ssh', '--yolo', '--help'], + ['open', '--yolo', '--help'], + ]) { + const out = Bun.spawnSync(['node', cliPath, ...argv], { env: childEnv }) + const text = `${out.stdout.toString()}${out.stderr.toString()}` + expect(out.exitCode).toBe(0) + expect(text).not.toContain('unknown option') + expect(text).toContain('--yolo, --dangerously-skip-permissions') + } + }, { timeout: 20000 }) + + it('has no production startup gates using naive includes print checks', async () => { + // All pre-Commander print-mode decisions must go through the shared + // arity-aware predicate. A naive .includes('-p') / .includes('--print') + // disagrees with Commander on value-consumed tokens such as + // `--system-prompt --print` or `--model -p`. + const files = [ + 'src/utils/interactivity.ts', + 'src/utils/earlyInput.ts', + 'src/utils/gracefulShutdown.ts', + 'src/utils/providerValidation.ts', + 'src/services/api/logging.ts', + 'src/cli/bg.ts', + 'src/main.tsx', + ] + for (const file of files) { + const src = await Bun.file(`${import.meta.dir}/../../${file}`).text() + expect(src).not.toMatch(/\.includes\(['"]-p['"]\)/) + expect(src).not.toMatch(/\.includes\(['"]--print['"]\)/) + } }) }) diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index d4e10f210..76b27d761 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -46,7 +46,7 @@ const SKILLS_LEADING_BOOLEAN_FLAGS = new Set([ '--bare', '--debug', '--debug-to-stderr', - '--yolo', + '--yolo', // alias for --dangerously-skip-permissions '--dangerously-skip-permissions', '--allow-dangerously-skip-permissions', '--disable-slash-commands', diff --git a/src/main.tsx b/src/main.tsx index d854a423d..6b563fd00 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -27,6 +27,8 @@ import pickBy from 'lodash-es/pickBy.js'; import uniqBy from 'lodash-es/uniqBy.js'; import React from 'react'; import { getOauthConfig } from './constants/oauth.js'; +import { parseSshFlags, sshArgvImpliesHeadless } from './utils/sshPreParse.js'; +import { hasPrintFlag } from './utils/printFlag.js'; import { getRemoteSessionUrl } from './constants/product.js'; import { getSystemContext, getUserContext } from './context.js'; import { init, initializeTelemetryAfterTrust } from './entrypoints/init.js'; @@ -55,6 +57,10 @@ import { getSubscriptionType, isClaudeAISubscriber, prefetchAwsCredentialsAndBed import { checkHasTrustDialogAccepted, getGlobalConfig, getRemoteControlAtStartup, isAutoUpdaterDisabled, saveGlobalConfig } from './utils/config.js'; import { seedEarlyInput, stopCapturingEarlyInput } from './utils/earlyInput.js'; import { clampUltracodeEffort, getInitialEffortSetting, parseEffortValue } from './utils/effort.js'; +import { + MAX_TURNS_CLI_DESCRIPTION, + parseMaxTurnsCommanderArgument, +} from './utils/replMaxTurns.js'; import { getInitialFastModeSetting, isFastModeEnabled, prefetchFastModeStatus, resolveFastModeStatusFromCache } from './utils/fastMode.js'; import { applyConfigEnvironmentVariables } from './utils/managedEnv.js'; import { createSystemMessage, createUserMessage } from './utils/messages.js'; @@ -123,7 +129,6 @@ import { getModelDeprecationWarning } from './utils/model/deprecation.js'; import { getDefaultMainLoopModel, getUserSpecifiedModelSetting, normalizeModelStringForAPI, parseUserSpecifiedModel } from './utils/model/model.js'; import { ensureModelStringsInitialized } from './utils/model/modelStrings.js'; import { PERMISSION_MODES } from './utils/permissions/PermissionMode.js'; -import { MAX_TURNS_CLI_DESCRIPTION, parseMaxTurnsCommanderArgument } from './utils/replMaxTurns.js'; import { checkAndDisableBypassPermissions, getAutoModeEnabledStateIfCached, initializeToolPermissionContext, initialPermissionModeFromCLI, isDefaultPermissionModeAuto, parseToolListFromCLI, stripDangerousPermissionsForAutoMode, verifyAutoModeGateAccess } from './utils/permissions/permissionSetup.js'; import { cleanupOrphanedPluginVersionsInBackground } from './utils/plugins/cacheUtils.js'; import { initializeVersionedPlugins } from './utils/plugins/installedPluginsManager.js'; @@ -513,12 +518,10 @@ function initializeEntrypoint(isNonInteractive: boolean): void { type PendingConnect = { url: string | undefined; authToken: string | undefined; - dangerouslySkipPermissions: boolean; }; const _pendingConnect: PendingConnect | undefined = feature('DIRECT_CONNECT') ? { url: undefined, - authToken: undefined, - dangerouslySkipPermissions: false + authToken: undefined } : undefined; // Set by early argv processing when `claude assistant [sessionId]` is detected @@ -570,7 +573,7 @@ export async function main() { // In print mode, print.ts registers its own SIGINT handler that aborts // the in-flight query and calls gracefulShutdown; skip here to avoid // preempting it with a synchronous process.exit(). - if (process.argv.includes('-p') || process.argv.includes('--print')) { + if (hasPrintFlag(process.argv)) { return; } process.exit(0); @@ -589,23 +592,21 @@ export async function main() { parseConnectUrl } = await import('./server/parseConnectUrl.js'); const parsed = parseConnectUrl(ccUrl); - _pendingConnect.dangerouslySkipPermissions = rawCliArgs.includes('--dangerously-skip-permissions') || rawCliArgs.includes('--yolo'); - if (rawCliArgs.includes('-p') || rawCliArgs.includes('--print')) { - // Headless: rewrite to internal `open` subcommand. Strip both the - // canonical flag and its alias — the `open` stub does not register - // either, and passing both would leave one behind as an unknown option. - const stripped = rawCliArgs - .filter((_, i) => i !== ccIdx) - .filter(arg => arg !== '--dangerously-skip-permissions' && arg !== '--yolo'); - process.argv = [process.argv[0]!, process.argv[1]!, 'open', ccUrl, ...stripped]; + // Only the cc:// URL is stripped here. The --dangerously-skip-permissions + // / --yolo flag is deliberately NOT detected or stripped: it flows to the + // main command (interactive) or the `open` subcommand (headless), both of + // which register it, so commander is the single authority — respecting + // option arity and the `--` end-of-options marker. The action then reads + // the parsed opts().dangerouslySkipPermissions (see below). + const withoutCcUrl = rawCliArgs.filter((_, i) => i !== ccIdx); + if (hasPrintFlag(rawCliArgs)) { + // Headless: rewrite to internal `open` subcommand + process.argv = [process.argv[0]!, process.argv[1]!, 'open', ccUrl, ...withoutCcUrl]; } else { - // Interactive: strip cc:// URL and both bypass spellings, run main command + // Interactive: strip cc:// URL, run main command _pendingConnect.url = parsed.serverUrl; _pendingConnect.authToken = parsed.authToken; - const stripped = rawCliArgs - .filter((_, i) => i !== ccIdx) - .filter(arg => arg !== '--dangerously-skip-permissions' && arg !== '--yolo'); - process.argv = [process.argv[0]!, process.argv[1]!, ...stripped]; + process.argv = [process.argv[0]!, process.argv[1]!, ...withoutCcUrl]; } } } @@ -680,69 +681,29 @@ export async function main() { // given, so `claude ssh --permission-mode auto host` and `claude ssh host // --permission-mode auto` are equivalent. The host check below only needs // to guard against `-h`/`--help` (which commander should handle). + let parsed: ReturnType | undefined; if (rawCliArgs[0] === 'ssh') { - const localIdx = rawCliArgs.indexOf('--local'); - if (localIdx !== -1) { - _pendingSSH.local = true; - rawCliArgs.splice(localIdx, 1); + // Flag pre-parse (arity-aware; see parseSshFlags) lives in a pure helper + // so the security-sensitive handling is unit-tested. + parsed = parseSshFlags(rawCliArgs); + _pendingSSH.local = parsed.local; + if (parsed.permissionMode !== undefined) { + _pendingSSH.permissionMode = parsed.permissionMode; } - // Remove both bypass spellings from the forwarded argv; the remote state - // is carried by _pendingSSH.dangerouslySkipPermissions, not by a flag. - for (let i = rawCliArgs.length - 1; i >= 0; i -= 1) { - const arg = rawCliArgs[i]; - if (arg === '--dangerously-skip-permissions' || arg === '--yolo') { - _pendingSSH.dangerouslySkipPermissions = true; - rawCliArgs.splice(i, 1); - } + _pendingSSH.dangerouslySkipPermissions = parsed.dangerouslySkipPermissions; + _pendingSSH.extraCliArgs.push(...parsed.extraCliArgs); + + // Headless (-p) mode is not supported with SSH in v1 — reject early + // before host/cwd extraction so print flags before the host are caught. + // The check covers both the remaining argv and flags forwarded to the + // remote spawn. + if (sshArgvImpliesHeadless(parsed, parsed.remaining.slice(1))) { + process.stderr.write('Error: headless (-p/--print) mode is not supported with openclaude ssh\n'); + gracefulShutdownSync(1); + return; } - const pmIdx = rawCliArgs.indexOf('--permission-mode'); - if (pmIdx !== -1 && rawCliArgs[pmIdx + 1] && !rawCliArgs[pmIdx + 1]!.startsWith('-')) { - _pendingSSH.permissionMode = rawCliArgs[pmIdx + 1]; - rawCliArgs.splice(pmIdx, 2); - } - const pmEqIdx = rawCliArgs.findIndex(a => a.startsWith('--permission-mode=')); - if (pmEqIdx !== -1) { - _pendingSSH.permissionMode = rawCliArgs[pmEqIdx]!.split('=')[1]; - rawCliArgs.splice(pmEqIdx, 1); - } - // Forward session-resume + model flags to the remote CLI's initial spawn. - // --continue/-c and --resume operate on the REMOTE session history - // (which persists under the remote's ~/.claude/projects//). - // --model controls which model the remote uses. - const extractFlag = (flag: string, opts: { - hasValue?: boolean; - as?: string; - } = {}) => { - const i = rawCliArgs.indexOf(flag); - if (i !== -1) { - _pendingSSH.extraCliArgs.push(opts.as ?? flag); - const val = rawCliArgs[i + 1]; - if (opts.hasValue && val && !val.startsWith('-')) { - _pendingSSH.extraCliArgs.push(val); - rawCliArgs.splice(i, 2); - } else { - rawCliArgs.splice(i, 1); - } - } - const eqI = rawCliArgs.findIndex(a => a.startsWith(`${flag}=`)); - if (eqI !== -1) { - _pendingSSH.extraCliArgs.push(opts.as ?? flag, rawCliArgs[eqI]!.slice(flag.length + 1)); - rawCliArgs.splice(eqI, 1); - } - }; - extractFlag('-c', { - as: '--continue' - }); - extractFlag('--continue'); - extractFlag('--resume', { - hasValue: true - }); - extractFlag('--model', { - hasValue: true - }); - extractFlag('--fallback-model', { - hasValue: true - }); + + rawCliArgs.splice(0, rawCliArgs.length, ...parsed.remaining); } // After pre-extraction, any remaining dash-arg at [1] is either -h/--help // (commander handles) or an unknown-to-ssh flag (fall through to commander @@ -757,14 +718,6 @@ export async function main() { } const rest = rawCliArgs.slice(consumed); - // Headless (-p) mode is not supported with SSH in v1 — reject early - // so the flag doesn't silently cause local execution. - if (rest.includes('-p') || rest.includes('--print')) { - process.stderr.write('Error: headless (-p/--print) mode is not supported with openclaude ssh\n'); - gracefulShutdownSync(1); - return; - } - // Rewrite argv so the main command sees remaining flags but not `ssh`. process.argv = [process.argv[0]!, process.argv[1]!, ...rest]; } @@ -948,7 +901,7 @@ async function run(): Promise { } catch (error) { throw new InvalidArgumentError(errorMessage(error)); } - })).option('--bare', 'Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. Sets CLAUDE_CODE_SIMPLE=1. Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via --settings (OAuth and keychain are never read). 3P providers (Bedrock/Vertex/Foundry) use their own credentials. Skills still resolve via /skill-name. Explicitly provide context via: --system-prompt[-file], --append-system-prompt[-file], --add-dir (CLAUDE.md dirs), --mcp-config, --settings, --agents, --plugin-dir.', () => true).addOption(new Option('--init', 'Run Setup hooks with init trigger, then continue').hideHelp()).addOption(new Option('--init-only', 'Run Setup and SessionStart:startup hooks, then exit').hideHelp()).addOption(new Option('--maintenance', 'Run Setup hooks with maintenance trigger, then continue').hideHelp()).addOption(new Option('--output-format ', 'Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming)').choices(['text', 'json', 'stream-json'])).addOption(new Option('--json-schema ', 'JSON Schema for structured output validation. ' + 'Example: {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}').argParser(String)).option('--include-hook-events', 'Include all hook lifecycle events in the output stream (only works with --output-format=stream-json)', () => true).option('--include-partial-messages', 'Include partial message chunks as they arrive (only works with --print and --output-format=stream-json)', () => true).addOption(new Option('--input-format ', 'Input format (only works with --print): "text" (default), or "stream-json" (realtime streaming input)').choices(['text', 'stream-json'])).option('--mcp-debug', '[DEPRECATED. Use --debug instead] Enable MCP debug mode (shows MCP server errors)', () => true).option('--yolo, --dangerously-skip-permissions', 'Bypass all permission checks. Recommended only for sandboxes with no internet access.', () => true).option('--allow-dangerously-skip-permissions', 'Enable bypassing all permission checks as an option, without it being enabled by default. Recommended only for sandboxes with no internet access.', () => true).addOption(new Option('--thinking ', 'Thinking mode: enabled (equivalent to adaptive), disabled').choices(['enabled', 'adaptive', 'disabled']).hideHelp()).addOption(new Option('--max-thinking-tokens ', '[DEPRECATED. Use --thinking instead for newer models] Maximum number of thinking tokens (only works with --print)').argParser(Number).hideHelp()).addOption(new Option('--max-turns ', MAX_TURNS_CLI_DESCRIPTION).argParser(value => { + })).option('--bare', 'Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. Sets CLAUDE_CODE_SIMPLE=1. Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via --settings (OAuth and keychain are never read). 3P providers (Bedrock/Vertex/Foundry) use their own credentials. Skills still resolve via /skill-name. Explicitly provide context via: --system-prompt[-file], --append-system-prompt[-file], --add-dir (CLAUDE.md dirs), --mcp-config, --settings, --agents, --plugin-dir.', () => true).addOption(new Option('--init', 'Run Setup hooks with init trigger, then continue').hideHelp()).addOption(new Option('--init-only', 'Run Setup and SessionStart:startup hooks, then exit').hideHelp()).addOption(new Option('--maintenance', 'Run Setup hooks with maintenance trigger, then continue').hideHelp()).addOption(new Option('--output-format ', 'Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming)').choices(['text', 'json', 'stream-json'])).addOption(new Option('--json-schema ', 'JSON Schema for structured output validation. ' + 'Example: {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}').argParser(String)).option('--include-hook-events', 'Include all hook lifecycle events in the output stream (only works with --output-format=stream-json)', () => true).option('--include-partial-messages', 'Include partial message chunks as they arrive (only works with --print and --output-format=stream-json)', () => true).addOption(new Option('--input-format ', 'Input format (only works with --print): "text" (default), or "stream-json" (realtime streaming input)').choices(['text', 'stream-json'])).option('--mcp-debug', '[DEPRECATED. Use --debug instead] Enable MCP debug mode (shows MCP server errors)', () => true).option('--yolo, --dangerously-skip-permissions', 'Bypass all permission checks (alias: --yolo). Recommended only for sandboxes with no internet access.', () => true).option('--allow-dangerously-skip-permissions', 'Enable bypassing all permission checks as an option, without it being enabled by default. Recommended only for sandboxes with no internet access.', () => true).addOption(new Option('--thinking ', 'Thinking mode: enabled (equivalent to adaptive), disabled').choices(['enabled', 'adaptive', 'disabled']).hideHelp()).addOption(new Option('--max-thinking-tokens ', '[DEPRECATED. Use --thinking instead for newer models] Maximum number of thinking tokens (only works with --print)').argParser(Number).hideHelp()).addOption(new Option('--max-turns ', MAX_TURNS_CLI_DESCRIPTION).argParser(value => { return parseMaxTurnsCommanderArgument(value); })).addOption(new Option('--max-budget-usd ', 'Maximum dollar amount to spend on API calls (only works with --print)').argParser(value => { const amount = Number(value); @@ -3130,7 +3083,8 @@ async function run(): Promise { serverUrl: _pendingConnect.url, authToken: _pendingConnect.authToken, cwd: getOriginalCwd(), - dangerouslySkipPermissions: _pendingConnect.dangerouslySkipPermissions + // Commander-authoritative: the flag flowed to the main command parse. + dangerouslySkipPermissions }); if (session.workDir) { setOriginalCwd(session.workDir); @@ -3750,7 +3704,7 @@ async function run(): Promise { // + 40ms sync keychain subprocess), both hidden by the try/catch that // always returns false before enableConfigs(). cc:// URLs are rewritten to // `open` at main() line ~851 BEFORE this runs, so argv check is safe here. - const isPrintMode = process.argv.includes('-p') || process.argv.includes('--print'); + const isPrintMode = hasPrintFlag(process.argv); const isCcUrl = process.argv.some(a => a.startsWith('cc://') || a.startsWith('cc+unix://')); if (isPrintMode && !isCcUrl) { profileCheckpoint('run_before_parse'); @@ -3914,7 +3868,7 @@ async function run(): Promise { // this action it means the argv rewrite didn't fire (e.g. user ran // `claude ssh` with no host) — just print usage. if (feature('SSH_REMOTE')) { - program.command('ssh [dir]').description('Run OpenClaude on a remote host over SSH. Deploys the binary and ' + 'tunnels API auth back through your local machine — no remote setup needed.').option('--permission-mode ', 'Permission mode for the remote session').option('--yolo, --dangerously-skip-permissions', 'Skip all permission prompts on the remote (dangerous)').option('--local', 'e2e test mode — spawn the child CLI locally (skip ssh/deploy). ' + 'Exercises the auth proxy and unix-socket plumbing without a remote host.').action(async () => { + program.command('ssh [dir]').description('Run OpenClaude on a remote host over SSH. Deploys the binary and ' + 'tunnels API auth back through your local machine — no remote setup needed.').option('--permission-mode ', 'Permission mode for the remote session').option('--yolo, --dangerously-skip-permissions', 'Skip all permission prompts on the remote (alias: --yolo; dangerous)').option('--local', 'e2e test mode — spawn the child CLI locally (skip ssh/deploy). ' + 'Exercises the auth proxy and unix-socket plumbing without a remote host.').action(async () => { // Argv rewriting in main() should have consumed `ssh ` before // commander runs. Reaching here means host was missing or the // rewrite predicate didn't match. @@ -3927,9 +3881,10 @@ async function run(): Promise { // Interactive mode (without -p) is handled by early argv rewriting in main() // which redirects to the main command with full TUI support. if (feature('DIRECT_CONNECT')) { - program.command('open ').description('Connect to an OpenClaude server (internal — use cc:// URLs)').option('-p, --print [prompt]', 'Print mode (headless)').option('--output-format ', 'Output format: text, json, stream-json', 'text').action(async (ccUrl: string, opts: { + program.command('open ').description('Connect to an OpenClaude server (internal — use cc:// URLs)').option('-p, --print [prompt]', 'Print mode (headless)').option('--output-format ', 'Output format: text, json, stream-json', 'text').option('--yolo, --dangerously-skip-permissions', 'Bypass all permission checks (alias: --yolo)', () => true).action(async (ccUrl: string, opts: { print?: string | boolean; outputFormat: string; + dangerouslySkipPermissions?: boolean; }) => { const { parseConnectUrl @@ -3944,7 +3899,7 @@ async function run(): Promise { serverUrl, authToken, cwd: getOriginalCwd(), - dangerouslySkipPermissions: _pendingConnect?.dangerouslySkipPermissions + dangerouslySkipPermissions: opts.dangerouslySkipPermissions ?? false }); if (session.workDir) { setOriginalCwd(session.workDir); diff --git a/src/services/api/logging.ts b/src/services/api/logging.ts index 8b380a262..d26c65d1d 100644 --- a/src/services/api/logging.ts +++ b/src/services/api/logging.ts @@ -21,6 +21,7 @@ import type { EffortLevel } from 'src/utils/effort.js' import { logError } from 'src/utils/log.js' import { getAPIProviderForStatsig } from 'src/utils/model/providers.js' import type { PermissionMode } from 'src/utils/permissions/PermissionMode.js' +import { hasPrintFlag } from 'src/utils/printFlag.js' import { redactSensitiveInfo, redactUrlForDisplay } from 'src/utils/redaction.js' import { jsonStringify } from 'src/utils/slowOperations.js' import type { NonNullableUsage } from '../../entrypoints/sdk/sdkUtilityTypes.js' @@ -427,8 +428,7 @@ function logAPISuccess({ }): void { const isNonInteractiveSession = getIsNonInteractiveSession() const isPostCompaction = consumePostCompaction() - const hasPrintFlag = - process.argv.includes('-p') || process.argv.includes('--print') + const isPrintMode = hasPrintFlag(process.argv) const now = Date.now() const lastCompletion = getLastApiCompletionTimestamp() @@ -481,7 +481,7 @@ function logAPISuccess({ costUSD, didFallBackToNonStreaming, isNonInteractiveSession, - print: hasPrintFlag, + print: isPrintMode, isTTY: process.stdout.isTTY ?? false, querySource: querySource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, diff --git a/src/setup.ts b/src/setup.ts index 4be37c318..9128908aa 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -396,7 +396,7 @@ export async function setup( ) { // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( - `--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons`, + `Permission-bypass flags/modes (e.g. --dangerously-skip-permissions / --yolo, --allow-dangerously-skip-permissions, or fullAccess mode) cannot be used with root/sudo privileges for security reasons`, ) process.exit(1) } diff --git a/src/utils/dangerousSkipFlags.test.ts b/src/utils/dangerousSkipFlags.test.ts new file mode 100644 index 000000000..670be9bf5 --- /dev/null +++ b/src/utils/dangerousSkipFlags.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'bun:test' +import { + hasDangerousSkipFlag, + isDangerousSkipFlag, + stripDangerousSkipFlags, +} from './dangerousSkipFlags.js' + +// Runtime coverage for the shared strip logic behind the direct-connect and +// ssh argv rewrites in main.tsx. The single-token removal these replaced let a +// second dangerous-skip token survive and silently re-enable bypass. +describe('dangerousSkipFlags', () => { + it('recognizes both the canonical flag and the --yolo alias', () => { + expect(isDangerousSkipFlag('--dangerously-skip-permissions')).toBe(true) + expect(isDangerousSkipFlag('--yolo')).toBe(true) + expect(isDangerousSkipFlag('--dangerously-skip')).toBe(false) + expect(isDangerousSkipFlag('--yolo=true')).toBe(false) + expect(isDangerousSkipFlag('-p')).toBe(false) + }) + + it('detects presence of either spelling', () => { + expect(hasDangerousSkipFlag(['ssh', 'host', '--yolo'])).toBe(true) + expect( + hasDangerousSkipFlag(['ssh', 'host', '--dangerously-skip-permissions']), + ).toBe(true) + expect(hasDangerousSkipFlag(['ssh', 'host', '-p', 'hi'])).toBe(false) + }) + + it('strips every dangerous-skip token — both spellings and repeats', () => { + expect( + stripDangerousSkipFlags([ + 'ssh', + '--yolo', + 'host', + '--dangerously-skip-permissions', + '--yolo', + '/tmp', + ]), + ).toEqual(['ssh', 'host', '/tmp']) + }) + + it('leaves argv untouched when no dangerous-skip token is present', () => { + const argv = ['ssh', 'host', '--permission-mode', 'auto'] + expect(stripDangerousSkipFlags(argv)).toEqual(argv) + }) + + it('does not mutate the input array', () => { + const argv = ['--yolo', 'x'] + stripDangerousSkipFlags(argv) + expect(argv).toEqual(['--yolo', 'x']) + }) +}) diff --git a/src/utils/dangerousSkipFlags.ts b/src/utils/dangerousSkipFlags.ts new file mode 100644 index 000000000..0822f73d3 --- /dev/null +++ b/src/utils/dangerousSkipFlags.ts @@ -0,0 +1,40 @@ +/** + * `--yolo` is registered as a native commander alias of + * `--dangerously-skip-permissions`, so the pre-commander argv scanners and the + * bypass safety notice must recognize either spelling. + * + * The SSH pre-parser strips every dangerous-skip token before forwarding the + * remaining argv, using the shared helpers below instead of ad-hoc single-token + * removal (which previously let a second token survive and silently re-enable + * bypass). + * + * The direct-connect `cc://` rewrite deliberately does NOT strip the flag: it + * removes only the `cc://` URL and leaves `--yolo` / `--dangerously-skip-permissions` + * for commander to parse on the main command (interactive) or the internal `open` + * subcommand (headless), so commander remains the single authority for option + * arity and the `--` end-of-options marker. + * + * These scanners run BEFORE commander and detect the flag by presence alone. + * That is deliberately an approximation — fully matching commander (which can + * consume `--yolo` as a required option value, or a `--` as a variadic value) + * would mean re-implementing commander's option-arity state machine, the exact + * fragile simulation this feature was reworked to delete. So the helper simply + * mirrors the long-standing behavior of the canonical `--dangerously-skip- + * permissions` scanning: `--yolo` and the canonical flag behave identically, + * no better and no worse. + */ + +const DANGEROUS_SKIP_FLAGS = ['--dangerously-skip-permissions', '--yolo'] + +export function isDangerousSkipFlag(arg: string): boolean { + return DANGEROUS_SKIP_FLAGS.includes(arg) +} + +export function hasDangerousSkipFlag(argv: readonly string[]): boolean { + return argv.some(isDangerousSkipFlag) +} + +/** Returns a copy of `argv` with every dangerous-skip token removed. */ +export function stripDangerousSkipFlags(argv: readonly string[]): string[] { + return argv.filter(arg => !isDangerousSkipFlag(arg)) +} diff --git a/src/utils/earlyInput.ts b/src/utils/earlyInput.ts index 734dad4f4..d928c8f12 100644 --- a/src/utils/earlyInput.ts +++ b/src/utils/earlyInput.ts @@ -11,6 +11,7 @@ * 3. stopCapturingEarlyInput() is called automatically when input is consumed */ +import { hasPrintFlag } from './printFlag.js' import { lastGrapheme } from './intl.js' // Buffer for early input characters @@ -33,8 +34,7 @@ export function startCapturingEarlyInput(): void { if ( !process.stdin.isTTY || isCapturing || - process.argv.includes('-p') || - process.argv.includes('--print') + hasPrintFlag(process.argv) ) { return } diff --git a/src/utils/gracefulShutdown.ts b/src/utils/gracefulShutdown.ts index 0b40c77df..849ab7b89 100644 --- a/src/utils/gracefulShutdown.ts +++ b/src/utils/gracefulShutdown.ts @@ -39,6 +39,7 @@ import type { AppState } from '../state/AppState.js' import { noteBackgroundSessionTerminationSignal } from './backgroundSessionTermination.js' import { runCleanupFunctions } from './cleanupRegistry.js' import { createCombinedAbortSignal } from './combinedAbortSignal.js' +import { hasPrintFlag } from './printFlag.js' import { logForDebugging } from './debug.js' import { logForDiagnosticsNoPII } from './diagLogs.js' import { @@ -270,7 +271,7 @@ export const setupGracefulShutdown = memoize(() => { // avoid racing with it. Only check print mode — other non-interactive // sessions (--sdk-url, --init-only, non-TTY) don't register their own // SIGINT handler and need gracefulShutdown to run. - if (process.argv.includes('-p') || process.argv.includes('--print')) { + if (hasPrintFlag(process.argv)) { return } noteBackgroundSessionTerminationSignal('SIGINT') diff --git a/src/utils/interactivity.test.ts b/src/utils/interactivity.test.ts index fdd526cf0..ccaa84440 100644 --- a/src/utils/interactivity.test.ts +++ b/src/utils/interactivity.test.ts @@ -1,4 +1,5 @@ import { expect, test, describe } from 'bun:test' +import { hasPrintFlag } from './printFlag.js' import { isInteractiveSession } from './interactivity.js' describe('isInteractiveSession', () => { @@ -76,4 +77,34 @@ describe('isInteractiveSession', () => { }), ).toBe(false) }) + + test('agrees with hasPrintFlag on value-consumed and genuine print argv', () => { + // If hasPrintFlag says the argv is not print mode, isInteractiveSession + // must not treat it as non-interactive just because a -p/--print token + // appears as a value. + const rows = [ + { args: [], print: false }, + { args: ['-p'], print: true }, + { args: ['--print'], print: true }, + { args: ['cc://host', '--print'], print: true }, + { args: ['--system-prompt', '--print'], print: false }, + { args: ['--system-prompt', '-p'], print: false }, + { args: ['--model', '-p'], print: false }, + { args: ['--permission-mode', '--print'], print: false }, + { args: ['--add-dir', '--print'], print: false }, + { args: ['--resume', '--print'], print: true }, + { args: ['--debug', '-p'], print: true }, + ] + + for (const { args, print } of rows) { + expect(hasPrintFlag(args)).toBe(print) + expect( + isInteractiveSession({ + stdoutIsTTY: true, + args, + env: { SSH_TTY: '/dev/pts/0' }, + }), + ).toBe(!print) + } + }) }) diff --git a/src/utils/interactivity.ts b/src/utils/interactivity.ts index ba2e8ac7d..8b7e67f75 100644 --- a/src/utils/interactivity.ts +++ b/src/utils/interactivity.ts @@ -1,3 +1,5 @@ +import { hasPrintFlag } from './printFlag.js' + /** * Determines if the current session should be treated as interactive. * Robustly handles SSH sessions which might not report TTY status accurately. @@ -10,11 +12,11 @@ export function isInteractiveSession(options: { const { stdoutIsTTY, args, env } = options; // Explicit non-interactive flags - const hasPrintFlag = args.includes('-p') || args.includes('--print'); + const isPrint = hasPrintFlag(args); const hasInitOnlyFlag = args.includes('--init-only'); const hasSdkUrl = args.some(arg => arg.startsWith('--sdk-url')); - if (hasPrintFlag || hasInitOnlyFlag || hasSdkUrl) { + if (isPrint || hasInitOnlyFlag || hasSdkUrl) { return false; } diff --git a/src/utils/printFlag.test.ts b/src/utils/printFlag.test.ts new file mode 100644 index 000000000..0336afeb9 --- /dev/null +++ b/src/utils/printFlag.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from 'bun:test' +import { hasPrintFlag } from './printFlag.js' + +describe('hasPrintFlag', () => { + test('detects the standalone -p and --print boolean flags', () => { + expect(hasPrintFlag(['-p'])).toBe(true) + expect(hasPrintFlag(['--print'])).toBe(true) + expect(hasPrintFlag(['cc://host', '--print'])).toBe(true) + }) + + test('does not treat invalid boolean spellings as print mode', () => { + // The root command rejects these; only exact `-p` / `--print` are valid. + expect(hasPrintFlag(['--print=prompt'])).toBe(false) + expect(hasPrintFlag(['-pprompt'])).toBe(false) + }) + + test('does not mistake bare positional text for the flag', () => { + expect(hasPrintFlag(['cc://host', 'prompt'])).toBe(false) + expect(hasPrintFlag(['cc://host', '-x', 'prompt'])).toBe(false) + }) + + test('stops at the -- end-of-options marker', () => { + expect(hasPrintFlag(['cc://host', '--', '--print'])).toBe(false) + expect(hasPrintFlag(['cc://host', '--', '-p'])).toBe(false) + }) + + test('does not classify a value of a required option as the print flag', () => { + expect(hasPrintFlag(['--system-prompt', '--print=custom'])).toBe(false) + expect(hasPrintFlag(['--model', '-p'])).toBe(false) + expect(hasPrintFlag(['--permission-mode', '--print'])).toBe(false) + expect(hasPrintFlag(['--system-prompt=--print=custom'])).toBe(false) + + // Required options consume `--` as their value, so the following flag is + // still parsed by Commander. + expect(hasPrintFlag(['--model', '--', '-p'])).toBe(true) + }) + + test('does not classify a value of a variadic option as the print flag', () => { + expect(hasPrintFlag(['--add-dir', '--print'])).toBe(false) + expect(hasPrintFlag(['--add-dir', '-p'])).toBe(false) + + // After the first value, variadic options stop at the next flag. + expect(hasPrintFlag(['--add-dir', 'foo', '--print'])).toBe(true) + }) + + test('does not classify a value of an optional option as the print flag unless explicitly provided', () => { + // Optional-value options do not consume a following flag, so --print/-p is + // still detected. + expect(hasPrintFlag(['--resume', '--print'])).toBe(true) + expect(hasPrintFlag(['--debug', '-p'])).toBe(true) + + // But a non-flag value is consumed and not mistaken for the print flag. + expect(hasPrintFlag(['--resume', 'print'])).toBe(false) + expect(hasPrintFlag(['--debug', 'p'])).toBe(false) + + // Optional options do not consume `--`. + expect(hasPrintFlag(['--resume', '--', '--print'])).toBe(false) + + // Hidden optional-value root options likewise leave following flags alone. + expect(hasPrintFlag(['--worktree', '--print'])).toBe(true) + expect(hasPrintFlag(['--teleport', '-p'])).toBe(true) + expect(hasPrintFlag(['--remote', 'print'])).toBe(false) + expect(hasPrintFlag(['--remote-control', 'rc'])).toBe(false) + }) + + test('does not classify a value of hidden required root options as the print flag', () => { + expect(hasPrintFlag(['--agent-id', '--print'])).toBe(false) + expect(hasPrintFlag(['--sdk-url', '-p'])).toBe(false) + expect(hasPrintFlag(['--agent-name', '--print'])).toBe(false) + expect(hasPrintFlag(['--team-name', '-p'])).toBe(false) + expect(hasPrintFlag(['--parent-session-id', '--print'])).toBe(false) + expect(hasPrintFlag(['--teammate-mode', '-p'])).toBe(false) + expect(hasPrintFlag(['--agent-type', '--print'])).toBe(false) + expect(hasPrintFlag(['--advisor', '-p'])).toBe(false) + expect(hasPrintFlag(['--messaging-socket-path', '--print'])).toBe(false) + expect(hasPrintFlag(['--agent-color', '-p'])).toBe(false) + }) + + test('does not classify a value of hidden variadic root options as the print flag', () => { + expect(hasPrintFlag(['--channels', '--print'])).toBe(false) + expect(hasPrintFlag(['--channels', '-p'])).toBe(false) + expect(hasPrintFlag(['--dangerously-load-development-channels', '--print'])).toBe(false) + + // After the first value, variadic options keep consuming consecutive + // non-flag values, so a later positional-looking value is still hidden. + expect(hasPrintFlag(['--channels', 'server1', 'server2', 'print'])).toBe(false) + + // Once the next token is a flag, variadic consumption stops and --print + // is detected normally. + expect(hasPrintFlag(['--channels', 'server1', '--print'])).toBe(true) + expect(hasPrintFlag(['--channels', 'server1', 'server2', '--print'])).toBe(true) + }) +}) diff --git a/src/utils/printFlag.ts b/src/utils/printFlag.ts new file mode 100644 index 000000000..38327dce9 --- /dev/null +++ b/src/utils/printFlag.ts @@ -0,0 +1,151 @@ +/** + * Detects the boolean `-p, --print` flag in raw argv using the root command's + * actual boolean spelling: exactly `-p` or `--print`. + * + * The scan is option-arity-aware so tokens consumed as values by preceding + * value-taking options are not mistaken for the print flag. Required-value and + * variadic options consume the next token unconditionally (including flag-like + * values and the `--` delimiter), matching Commander's behavior. Optional-value + * options consume the next token only when it is not a flag and not `--`. + * + * This intentionally mirrors Commander's consumption rules closely enough for + * the pre-commander startup routing decisions (direct-connect headless rewrite, + * SSH headless rejection, and the SIGINT handler) without re-implementing the + * full program parser. + */ + +// Options registered on the root command that take a required value. The next +// token is always their value, even if it looks like a flag or is `--`. +const REQUIRED_VALUE_OPTIONS = new Set([ + '--debug-file', + '--heartbeat', + '--output-format', + '--json-schema', + '--max-thinking-tokens', + '--max-turns', + '--max-budget-usd', + '--task-budget', + '--thinking', + '--system-prompt', + '--system-prompt-file', + '--append-system-prompt', + '--append-system-prompt-file', + '--permission-mode', + '--model', + '--provider', + '--effort', + '--agent', + '--fallback-model', + '--workload', + '--settings', + '--name', + '-n', + '--agents', + '--setting-sources', + '--session-id', + '--plugin-dir', + '--provider-env-file', + '--deep-link-repo', + '--deep-link-last-fetch', + '--resume-session-at', + '--rewind-files', + '--prefill', + '--permission-prompt-tool', + '--input-format', + '--agent-id', + '--agent-name', + '--agent-type', + '--agent-color', + '--team-name', + '--parent-session-id', + '--teammate-mode', + '--advisor', + '--messaging-socket-path', + '--sdk-url', +]) + +// Variadic options consume their first value unconditionally, then keep +// consuming consecutive non-flag values. The first value may therefore be a +// flag or `--`, matching Commander's behavior. +const VARIADIC_OPTIONS = new Set([ + '--add-dir', + '--mcp-config', + '--file', + '--tools', + '--allowed-tools', + '--allowedTools', + '--disallowed-tools', + '--disallowedTools', + '--betas', + '--channels', + '--dangerously-load-development-channels', +]) + +// Options that take an optional value. They consume the next token only when +// it does not start with `-` and is not `--`, so a following flag remains +// available for its own parsing. +const OPTIONAL_VALUE_OPTIONS = new Set(['--debug', '-d', '--resume', '-r', '--from-pr', '--worktree', '-w', '--teleport', '--remote', '--remote-control', '--rc']) + +function optionName(arg: string): string { + const eq = arg.indexOf('=') + return eq === -1 ? arg : arg.slice(0, eq) +} + +function isPrintFlag(arg: string): boolean { + // The root command registers `-p, --print` as a boolean. Only the exact + // spellings are valid; `--print=prompt` and `-pprompt` are rejected by the + // root command parser. + return arg === '-p' || arg === '--print' +} + +export function hasPrintFlag(argv: readonly string[]): boolean { + let i = 0 + while (i < argv.length) { + const arg = argv[i]! + if (arg === '--') break + + const name = optionName(arg) + + if (REQUIRED_VALUE_OPTIONS.has(name)) { + // Inline value (`--model=foo`) stays in this token. Otherwise the next + // token is consumed as the value, even if it is `--` or another flag. + i += arg.includes('=') ? 1 : 2 + continue + } + + if (VARIADIC_OPTIONS.has(name)) { + if (arg.includes('=')) { + i++ + } else { + // First variadic value is consumed unconditionally (flag or `--` is + // allowed); after that, only non-flag values are consumed. + i += 2 + while ( + i < argv.length && + !argv[i]!.startsWith('-') && + argv[i] !== '--' + ) { + i++ + } + } + continue + } + + if (OPTIONAL_VALUE_OPTIONS.has(name)) { + const next = argv[i + 1] + if (next !== undefined && next !== '--' && !next.startsWith('-')) { + i += 2 + } else { + i++ + } + continue + } + + if (isPrintFlag(arg)) { + return true + } + + i++ + } + return false +} diff --git a/src/utils/providerValidation.ts b/src/utils/providerValidation.ts index 6f4f2ed44..963287bbd 100644 --- a/src/utils/providerValidation.ts +++ b/src/utils/providerValidation.ts @@ -1,4 +1,5 @@ import { resolve } from 'node:path' +import { hasPrintFlag } from './printFlag.js' import '../integrations/index.js' import { ensureIntegrationsLoaded, @@ -691,8 +692,7 @@ export function shouldExitForStartupProviderValidationError(options: { } return ( - args.includes('-p') || - args.includes('--print') || + hasPrintFlag(args) || args.includes('--init-only') || args.some(arg => arg.startsWith('--sdk-url')) ) diff --git a/src/utils/sshPreParse.test.ts b/src/utils/sshPreParse.test.ts new file mode 100644 index 000000000..7308fc536 --- /dev/null +++ b/src/utils/sshPreParse.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'bun:test' +import { parseSshFlags, sshArgvImpliesHeadless } from './sshPreParse.js' + +function headlessFrom(raw: string[]): boolean { + const parsed = parseSshFlags(raw) + // Mirror main.tsx host/cwd extraction: after pre-parse, host is the first + // non-dash token at [1]; cwd is the next non-dash token; the rest is tail. + const args = parsed.remaining + if (args[0] !== 'ssh' || !args[1] || args[1].startsWith('-')) { + return sshArgvImpliesHeadless(parsed, []) + } + let consumed = 2 + if (args[consumed] && !args[consumed]!.startsWith('-')) { + consumed = 3 + } + return sshArgvImpliesHeadless(parsed, args.slice(consumed)) +} +describe('parseSshFlags', () => { + it('extracts host-adjacent flags without enabling bypass', () => { + const r = parseSshFlags(['ssh', 'host', '--permission-mode', 'auto']) + expect(r.permissionMode).toBe('auto') + expect(r.dangerouslySkipPermissions).toBe(false) + expect(r.remaining).toEqual(['ssh', 'host']) + }) + + it('enables bypass for a genuine standalone --yolo / canonical flag', () => { + expect(parseSshFlags(['ssh', 'host', '--yolo']).dangerouslySkipPermissions).toBe(true) + expect( + parseSshFlags(['ssh', 'host', '--dangerously-skip-permissions']) + .dangerouslySkipPermissions, + ).toBe(true) + // both spellings + a repeat are all stripped, none survives into remaining + const r = parseSshFlags(['ssh', 'host', '--yolo', '--dangerously-skip-permissions', '--yolo']) + expect(r.dangerouslySkipPermissions).toBe(true) + expect(r.remaining).toEqual(['ssh', 'host']) + }) + + it('does NOT enable bypass when --yolo is the value of --permission-mode (escalation guard)', () => { + // Commander would parse --yolo as the (invalid) mode value and reject it; + // the pre-parser must not treat it as a bypass flag. + const r = parseSshFlags(['ssh', 'host', '--permission-mode', '--yolo']) + expect(r.dangerouslySkipPermissions).toBe(false) + expect(r.permissionMode).toBe('--yolo') + expect(r.remaining).toEqual(['ssh', 'host']) + }) + + it('does NOT enable bypass when --yolo is the value of --model (escalation guard)', () => { + const r = parseSshFlags(['ssh', 'host', '--model', '--yolo']) + expect(r.dangerouslySkipPermissions).toBe(false) + expect(r.extraCliArgs).toEqual(['--model', '--yolo']) + expect(r.remaining).toEqual(['ssh', 'host']) + }) + + it('still enables bypass alongside a legitimately-valued flag', () => { + // --permission-mode consumes `auto`; the separate --yolo is a real bypass. + const r = parseSshFlags(['ssh', '--permission-mode', 'auto', 'host', '--yolo']) + expect(r.permissionMode).toBe('auto') + expect(r.dangerouslySkipPermissions).toBe(true) + expect(r.remaining).toEqual(['ssh', 'host']) + }) + + it('treats flags after -- as positional: no bypass, no --local', () => { + // `ssh host -- --yolo` / `ssh host -- --local` — everything after -- is + // positional and must not be parsed as options. + const y = parseSshFlags(['ssh', 'host', '--', '--yolo']) + expect(y.dangerouslySkipPermissions).toBe(false) + expect(y.remaining).toEqual(['ssh', 'host', '--', '--yolo']) + + const l = parseSshFlags(['ssh', 'host', '--', '--local', '--permission-mode', 'x']) + expect(l.local).toBe(false) + expect(l.permissionMode).toBeUndefined() + expect(l.dangerouslySkipPermissions).toBe(false) + expect(l.remaining).toEqual(['ssh', 'host', '--', '--local', '--permission-mode', 'x']) + }) + + it('still parses flags before -- while leaving the rest positional', () => { + const r = parseSshFlags(['ssh', '--yolo', 'host', '--', '--model', 'x']) + expect(r.dangerouslySkipPermissions).toBe(true) + expect(r.remaining).toEqual(['ssh', 'host', '--', '--model', 'x']) + }) + + it('consumes every occurrence of value-taking flags left-to-right, including flag-like values', () => { + const r = parseSshFlags(['ssh', 'host', '--model', 'ok', '--model', '--yolo', 'value']) + expect(r.extraCliArgs).toEqual(['--model', 'ok', '--model', '--yolo']) + expect(r.dangerouslySkipPermissions).toBe(false) + expect(r.remaining).toEqual(['ssh', 'host', 'value']) + }) + + it('does not let --local interfere with --permission-mode value consumption', () => { + const r = parseSshFlags(['ssh', 'host', '--permission-mode', '--local']) + expect(r.permissionMode).toBe('--local') + expect(r.local).toBe(false) + expect(r.dangerouslySkipPermissions).toBe(false) + expect(r.remaining).toEqual(['ssh', 'host']) + }) + + it('consumes equals forms of value-taking flags and preserves embedded =', () => { + const r = parseSshFlags([ + 'ssh', + 'host', + '--permission-mode=fullAccess', + '--model=provider=model', + '--resume=abc=def', + ]) + expect(r.permissionMode).toBe('fullAccess') + // Required-value options are forwarded as separate tokens; the optional + // `--resume` keeps its inline value attached to preserve optional-value + // semantics for flag-like resume names. + expect(r.extraCliArgs).toEqual([ + '--model', + 'provider=model', + '--resume=abc=def', + ]) + expect(r.remaining).toEqual(['ssh', 'host']) + }) + + it('preserves value-taking flags that lack a value so commander can error', () => { + const last = parseSshFlags(['ssh', 'host', '--model']) + expect(last.extraCliArgs).toEqual([]) + expect(last.remaining).toEqual(['ssh', 'host', '--model']) + + // Commander treats `--` as a valid required value, so the pre-parser does + // too; the remaining argv loses the option and its value. + const beforeEoo = parseSshFlags(['ssh', 'host', '--permission-mode', '--', 'x']) + expect(beforeEoo.permissionMode).toBe('--') + expect(beforeEoo.remaining).toEqual(['ssh', 'host', 'x']) + }) + + it('consumes -- as the value of a preceding required SSH option', () => { + const r = parseSshFlags(['ssh', 'host', '--model', '--', '--yolo']) + expect(r.extraCliArgs).toEqual(['--model', '--']) + expect(r.dangerouslySkipPermissions).toBe(true) + expect(r.remaining).toEqual(['ssh', 'host']) + }) + + it('forwards bare --resume and consumes only a non-option value', () => { + const bare = parseSshFlags(['ssh', 'host', '--resume']) + expect(bare.extraCliArgs).toEqual(['--resume']) + expect(bare.remaining).toEqual(['ssh', 'host']) + + const withValue = parseSshFlags(['ssh', 'host', '--resume', 'abc']) + expect(withValue.extraCliArgs).toEqual(['--resume', 'abc']) + expect(withValue.remaining).toEqual(['ssh', 'host']) + + const beforeFlag = parseSshFlags(['ssh', 'host', '--resume', '--yolo']) + expect(beforeFlag.extraCliArgs).toEqual(['--resume']) + expect(beforeFlag.dangerouslySkipPermissions).toBe(true) + expect(beforeFlag.remaining).toEqual(['ssh', 'host']) + }) + +function headlessBeforeHost(raw: string[]): boolean { + const parsed = parseSshFlags(raw) + // Mirror main.tsx pre-host-extraction check: scan the whole remaining argv + // (excluding the `ssh` subcommand token) plus forwarded extraCliArgs. + return sshArgvImpliesHeadless(parsed, parsed.remaining.slice(1)) +} + + it('detects headless mode for standalone print tokens', () => { + // A genuine standalone print flag in the tail is caught. + expect(headlessFrom(['ssh', 'host', '-p'])).toBe(true) + expect(headlessFrom(['ssh', 'host', '--print'])).toBe(true) + + // A bare `--resume` followed by `--print` is also print mode (optional value + // does not consume a flag-like token). + expect(headlessFrom(['ssh', 'host', '--resume', '--print'])).toBe(true) + }) + + it('does not treat an inline --resume value as headless mode', () => { + // `--resume=--print` explicitly names a conversation "--print"; the value + // is preserved and must not be blocked. + expect(headlessFrom(['ssh', '--resume=--print', 'host'])).toBe(false) + expect(headlessFrom(['ssh', 'host', '--resume=--print'])).toBe(false) + expect(headlessBeforeHost(['ssh', '--resume=--print', 'host'])).toBe(false) + }) + + it('detects headless print flags before the host', () => { + expect(headlessBeforeHost(['ssh', '--print', 'host'])).toBe(true) + expect(headlessBeforeHost(['ssh', '-p', 'host'])).toBe(true) + // A boolean print flag followed by an unrelated positional is still print mode. + expect(headlessBeforeHost(['ssh', '--print', 'prompt', 'host'])).toBe(true) + // An invalid `--print=prompt` token is not the boolean print flag. + expect(headlessBeforeHost(['ssh', '--print=prompt', 'host'])).toBe(false) + }) + + it('does not treat a value-consumed print token as headless mode', () => { + // `--model -p` forwards -p as the model value; it must not be blocked. + expect(headlessFrom(['ssh', 'host', '--model', '-p'])).toBe(false) + expect(headlessFrom(['ssh', '--model', '-p', 'host'])).toBe(false) + + // Same for other required-value SSH options. + expect(headlessFrom(['ssh', 'host', '--fallback-model', '--print'])).toBe(false) + }) +}) diff --git a/src/utils/sshPreParse.ts b/src/utils/sshPreParse.ts new file mode 100644 index 000000000..9d44db2a3 --- /dev/null +++ b/src/utils/sshPreParse.ts @@ -0,0 +1,174 @@ +import { hasDangerousSkipFlag, stripDangerousSkipFlags } from './dangerousSkipFlags.js' +import { hasPrintFlag } from './printFlag.js' + +/** + * Result of pre-parsing the flags of a `claude ssh …` invocation, before the + * host/cwd positionals and the argv rewrite. Extracted from main.tsx so the + * security-sensitive arity handling can be unit-tested. + */ +export interface SshFlagParse { + local: boolean + permissionMode: string | undefined + dangerouslySkipPermissions: boolean + /** Flags to forward to the remote CLI's initial spawn (e.g. --model ). */ + extraCliArgs: string[] + /** `args` with every consumed flag removed; still starts with `ssh`. */ + remaining: string[] +} + +/** + * Pull SSH-relevant flags out of `rawCliArgs` (which starts with `ssh`). + * + * Recognized options are parsed in a single left-to-right arity-aware pass. + * Value-taking flags consume the next token unconditionally — matching + * commander's required-argument behavior — so a value that looks like a flag + * (e.g. `--model --print` or `--permission-mode --local`) or even the `--` + * delimiter itself (e.g. `--model --`) is never left in the remaining argv to + * be misinterpreted by later guards. Every occurrence of a recognized option, + * including equals forms, is consumed. + * + * A bare `--` that is not consumed as a value terminates option parsing: every + * token at/after it is kept as positional input and is never parsed as a flag. + */ +export function parseSshFlags(rawCliArgs: readonly string[]): SshFlagParse { + const args = [...rawCliArgs] + let local = false + let permissionMode: string | undefined + let dangerouslySkipPermissions = false + const extraCliArgs: string[] = [] + const remaining: string[] = [] + const trailing: string[] = [] + + let i = 0 + while (i < args.length) { + const arg = args[i]! + + // End-of-options marker: stop parsing, but only if it is not the value of + // a preceding required option. Optional-value options never consume `--`. + if (arg === '--') { + trailing.push(...args.slice(i)) + break + } + + if (arg === '--local') { + local = true + i++ + continue + } + + if (arg === '-c' || arg === '--continue') { + extraCliArgs.push('--continue') + i++ + continue + } + + if (arg === '--permission-mode') { + const next = args[i + 1] + if (next !== undefined) { + permissionMode = next + i += 2 + } else { + remaining.push(arg) + i++ + } + continue + } + if (arg.startsWith('--permission-mode=')) { + permissionMode = arg.slice('--permission-mode='.length) + i++ + continue + } + + if (arg === '--model') { + const next = args[i + 1] + if (next !== undefined) { + extraCliArgs.push('--model', next) + i += 2 + } else { + remaining.push(arg) + i++ + } + continue + } + if (arg.startsWith('--model=')) { + extraCliArgs.push('--model', arg.slice('--model='.length)) + i++ + continue + } + + if (arg === '--fallback-model') { + const next = args[i + 1] + if (next !== undefined) { + extraCliArgs.push('--fallback-model', next) + i += 2 + } else { + remaining.push(arg) + i++ + } + continue + } + if (arg.startsWith('--fallback-model=')) { + extraCliArgs.push('--fallback-model', arg.slice('--fallback-model='.length)) + i++ + continue + } + + if (arg === '--resume') { + // Commander declares `--resume [value]`: a bare flag opens the resume + // picker, and a value is used only when it is a non-option, non-`--` + // token. + const next = args[i + 1] + if (next !== undefined && next !== '--' && !next.startsWith('-')) { + extraCliArgs.push('--resume', next) + i += 2 + } else { + extraCliArgs.push('--resume') + i++ + } + continue + } + if (arg.startsWith('--resume=')) { + // Equals form explicitly provides a value; keep it attached so the + // optional-value semantics are preserved on the remote CLI. For example, + // `--resume=--print` resumes a conversation named "--print"; it must not + // be misinterpreted as enabling print mode. + extraCliArgs.push(arg) + i++ + continue + } + + remaining.push(arg) + i++ + } + + // Every value-taking flag has now consumed its value, so any remaining + // dangerous-skip token is a genuine standalone bypass flag. Tokens after `--` + // are positional and must not be considered. + if (hasDangerousSkipFlag(remaining)) { + dangerouslySkipPermissions = true + const stripped = stripDangerousSkipFlags(remaining) + remaining.length = 0 + remaining.push(...stripped) + } + + return { + local, + permissionMode, + dangerouslySkipPermissions, + extraCliArgs, + remaining: [...remaining, ...trailing], + } +} + +/** + * After `parseSshFlags()` extracts host-independent flags into `extraCliArgs`, a + * headless/print token can hide there as well as in the tail argv after host/cwd. + * Centralize the SSH headless check so both scopes are covered with the same + * arity-aware predicate. + */ +export function sshArgvImpliesHeadless( + parsed: SshFlagParse, + rest: string[], +): boolean { + return hasPrintFlag(rest) || hasPrintFlag(parsed.extraCliArgs) +} diff --git a/src/utils/statusNoticeDefinitions.safety.test.tsx b/src/utils/statusNoticeDefinitions.safety.test.tsx index 7554ffa4e..cffa7d666 100644 --- a/src/utils/statusNoticeDefinitions.safety.test.tsx +++ b/src/utils/statusNoticeDefinitions.safety.test.tsx @@ -41,7 +41,11 @@ const SAVED_API_KEY = process.env.ANTHROPIC_API_KEY beforeEach(() => { // Reset argv each test so the dangerously-skip-permissions detector starts // from a known baseline. - process.argv = [...SAVED_ARGV.filter(a => a !== '--dangerously-skip-permissions')] + process.argv = [ + ...SAVED_ARGV.filter( + a => a !== '--dangerously-skip-permissions' && a !== '--yolo', + ), + ] // Other status notices read auth state via getAnthropicApiKeyWithSource, // which throws when no key/token is present. Seed a dummy so getActiveNotices // can iterate every notice without unrelated failures crashing the test. @@ -136,17 +140,84 @@ describe('third-party permissive mode notice (#244 finding 1)', () => { }) describe('dangerously-skip-permissions sandbox notice (#244 finding 2)', () => { - test('fires when --dangerously-skip-permissions is in argv', () => { - process.argv = [...process.argv, '--dangerously-skip-permissions'] - expect(activeIds(buildContext())).toContain('dangerously-skip-permissions-no-sandbox') - }) - - test('fires when permission mode is bypassPermissions (e.g. settings defaultMode)', () => { + // The notice is Commander-authoritative: both --dangerously-skip-permissions + // and its --yolo alias are resolved into permissionMode === 'bypassPermissions' + // during startup, while --permission-mode fullAccess resolves to 'fullAccess'. + // The notice keys off the resolved mode, not raw argv. + test('fires when permission mode is bypassPermissions (either spelling, or settings defaultMode)', () => { expect(activeIds(buildContext({ permissionMode: 'bypassPermissions' }))).toContain( 'dangerously-skip-permissions-no-sandbox', ) }) + test('fires when permission mode is fullAccess', () => { + expect(activeIds(buildContext({ permissionMode: 'fullAccess' }))).toContain( + 'dangerously-skip-permissions-no-sandbox', + ) + }) + + test('rendered notice names the mode, not a specific flag, so settings-driven bypass is not mislabeled', async () => { + const notice = await renderNoticePlainText( + 'dangerously-skip-permissions-no-sandbox', + buildContext({ permissionMode: 'bypassPermissions' }), + ) + // The notice fires whenever permissionMode is bypassPermissions, which can + // come from the CLI flags or from a settings defaultMode. + expect(notice).toContain('bypassPermissions') + expect(notice).not.toContain('--dangerously-skip-permissions') + expect(notice).not.toContain('--yolo') + }) + + test('rendered notice names fullAccess when that mode is active', async () => { + const notice = await renderNoticePlainText( + 'dangerously-skip-permissions-no-sandbox', + buildContext({ permissionMode: 'fullAccess' }), + ) + expect(notice).toContain('fullAccess') + expect(notice).not.toContain('--dangerously-skip-permissions') + expect(notice).not.toContain('--yolo') + }) + + test('bypassPermissions notice describes bypassed checks with remaining guardrails', async () => { + const notice = await renderNoticePlainText( + 'dangerously-skip-permissions-no-sandbox', + buildContext({ permissionMode: 'bypassPermissions' }), + ) + expect(notice).toContain('Most tool consent checks are bypassed') + expect(notice).toContain('safety-check guardrails still apply') + expect(notice).not.toContain('All tool consent checks are bypassed') + }) + + test('fullAccess notice describes the stronger bypass without overstating it', async () => { + const notice = await renderNoticePlainText( + 'dangerously-skip-permissions-no-sandbox', + buildContext({ permissionMode: 'fullAccess' }), + ) + const normalized = notice.replace(/\s+/g, ' ') + expect(normalized).toContain('Most tool consent checks are bypassed') + expect(normalized).toContain('including safety-check prompts') + expect(normalized).toContain('Hard deny rules and user-interaction prompts still apply') + expect(normalized).not.toContain('All tool consent checks are bypassed') + }) + + test('treats the SDK full-access spelling as the stronger fullAccess bypass', async () => { + const ctx = buildContext({ + permissionMode: 'full-access' as unknown as StatusNoticeContext['permissionMode'], + }) + expect(activeIds(ctx)).toContain('dangerously-skip-permissions-no-sandbox') + + const notice = await renderNoticePlainText( + 'dangerously-skip-permissions-no-sandbox', + ctx, + ) + const normalized = notice.replace(/\s+/g, ' ') + expect(normalized).toContain('full-access') + expect(normalized).toContain('Most tool consent checks are bypassed') + expect(normalized).toContain('including safety-check prompts') + expect(normalized).toContain('Hard deny rules and user-interaction prompts still apply') + expect(normalized).not.toContain('All tool consent checks are bypassed') + }) + test('does not fire in default mode without the flag', () => { expect(activeIds(buildContext({ permissionMode: 'default' }))).not.toContain( 'dangerously-skip-permissions-no-sandbox', @@ -175,10 +246,10 @@ describe('safety notice rendering', () => { `${figures.warning}bypassPermissions`, ) expect(dangerouslySkipNotice).toContain( - `${figures.warning} --dangerously-skip-permissions`, + `${figures.warning} bypassPermissions`, ) expect(dangerouslySkipNotice).not.toContain( - `${figures.warning}--dangerously-skip-permissions`, + `${figures.warning}bypassPermissions`, ) expect( thirdPartyNotice diff --git a/src/utils/statusNoticeDefinitions.tsx b/src/utils/statusNoticeDefinitions.tsx index 9899822f4..f104ac7af 100644 --- a/src/utils/statusNoticeDefinitions.tsx +++ b/src/utils/statusNoticeDefinitions.tsx @@ -13,7 +13,7 @@ import { getAgentDescriptionsTotalTokens, AGENT_DESCRIPTIONS_THRESHOLD } from '. import { isSupportedJetBrainsTerminal, toIDEDisplayName, getTerminalIdeType } from './ide.js'; import { isJetBrainsPluginInstalledCachedSync } from './jetbrains.js'; import type { LocalModelContextWarning } from './statusNoticeLocalModel.js'; -import type { PermissionMode } from './permissions/PermissionMode.js'; +import { isDangerousPermissionMode, type PermissionMode } from './permissions/PermissionMode.js'; import { modelSupportsAutoMode } from './betas.js'; import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from './model/providers.js'; import { logForDebugging } from './debug.js'; @@ -284,31 +284,53 @@ const thirdPartyPermissiveModeNotice: StatusNoticeDefinition = { ; } }; -// `--dangerously-skip-permissions` (a.k.a. bypassPermissions) auto-approves -// every tool call. On first-party builds an employee-only sandbox check -// (Docker/Bubblewrap + no internet) gates this flag; external users skip the -// check entirely (setup.ts), so the flag is effectively "run any command with -// no review". Warn loudly. Detection reads from process.argv so the notice -// fires from the first frame, before any AppState mode change propagates. -// See issue #244 finding 2. -function hasDangerouslySkipPermissionsArg(): boolean { - return process.argv.includes('--dangerously-skip-permissions'); +// `--dangerously-skip-permissions` (a.k.a. bypassPermissions) and `fullAccess` +// suppress the normal per-tool consent prompt, but they differ in what +// guardrails remain: +// +// - bypassPermissions still honors deny rules, user-interaction prompts, +// content-specific ask rules, and safety-check guardrails (e.g. .git/, +// .claude/, shell configs). +// - fullAccess skips those safety-check prompts too and is the stronger bypass. +// +// The SDK also accepts the kebab-case `full-access` alias and normalizes it to +// the internal camelCase mode, but a context may carry the external spelling +// directly. Treat both spellings as the stronger bypass so the warning does not +// accidentally downgrade to the weaker message. +// +// On first-party builds an employee-only sandbox check (Docker/Bubblewrap + no +// internet) gates these modes; external users skip the check entirely +// (setup.ts), so they are effectively "run any command with no review". Warn +// loudly. Commander resolves `--dangerously-skip-permissions` (and its `--yolo` +// alias) into the permission mode during startup, while `fullAccess` can be set +// via `--permission-mode` or settings, so this notice keys off the resolved +// `ctx.permissionMode` rather than re-scanning raw argv. That keeps it +// authoritative and avoids re-implementing commander's option arity / `--` +// semantics. See issue #244 finding 2. +function isDangerousMode(mode: PermissionMode | undefined): boolean { + return isDangerousPermissionMode(mode) || (mode as string) === 'full-access' +} +function isFullAccessMode(mode: PermissionMode | undefined): boolean { + return mode === 'fullAccess' || (mode as string) === 'full-access' } const dangerouslySkipPermissionsNotice: StatusNoticeDefinition = { id: 'dangerously-skip-permissions-no-sandbox', type: 'warning', - isActive: ctx => - hasDangerouslySkipPermissionsArg() || - ctx.permissionMode === 'bypassPermissions', - render: () => - - --dangerously-skip-permissions is active. - - - Every tool consent check is bypassed. Only use inside a sandbox with no internet access. - Restart without the flag to re-enable prompts. - - + isActive: ctx => isDangerousMode(ctx.permissionMode), + render: ctx => { + const mode = ctx.permissionMode; + const isFullAccess = isFullAccessMode(mode); + return + + {mode} mode is active. + + + {isFullAccess + ? 'Most tool consent checks are bypassed, including safety-check prompts. Hard deny rules and user-interaction prompts still apply. Only use inside a sandbox with no internet access. Restart without the bypass flag/mode to re-enable prompts.' + : 'Most tool consent checks are bypassed. Deny rules, user-interaction prompts, content-specific ask rules, and safety-check guardrails still apply. Only use inside a sandbox with no internet access.'} + + + }, }; // All notice definitions