mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
refactor(cli): commander-authoritative argv handling for SSH / cc:// and remote bypass (recovery of #1939) (#2098)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * test(cli): update ssh-path source assertion for parseSshFlags extraction Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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<value>). - 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * fixup! address jatmn review: arity-aware print/SSH parsing + fullAccess notice Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fixup! correct bypassPermissions notice wording Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
294bd9a1df
commit
787f2a9390
+2
-2
@@ -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[] {
|
||||
|
||||
+208
-152
@@ -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<CliMain>[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<CliMain>[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 <host> [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<string, string | undefined> = {
|
||||
...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['"]\)/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
+48
-93
@@ -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<typeof parseSshFlags> | 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 <uuid> operate on the REMOTE session history
|
||||
// (which persists under the remote's ~/.claude/projects/<cwd>/).
|
||||
// --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<CommanderCommand> {
|
||||
} 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 <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 <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 <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 <mode>', 'Thinking mode: enabled (equivalent to adaptive), disabled').choices(['enabled', 'adaptive', 'disabled']).hideHelp()).addOption(new Option('--max-thinking-tokens <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 <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 <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 <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 <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 <mode>', 'Thinking mode: enabled (equivalent to adaptive), disabled').choices(['enabled', 'adaptive', 'disabled']).hideHelp()).addOption(new Option('--max-thinking-tokens <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 <turns>', MAX_TURNS_CLI_DESCRIPTION).argParser(value => {
|
||||
return parseMaxTurnsCommanderArgument(value);
|
||||
})).addOption(new Option('--max-budget-usd <amount>', '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<CommanderCommand> {
|
||||
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<CommanderCommand> {
|
||||
// + 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<CommanderCommand> {
|
||||
// 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 <host> [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 <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 <host> [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 <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 <host>` before
|
||||
// commander runs. Reaching here means host was missing or the
|
||||
// rewrite predicate didn't match.
|
||||
@@ -3927,9 +3881,10 @@ async function run(): Promise<CommanderCommand> {
|
||||
// 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 <cc-url>').description('Connect to an OpenClaude server (internal — use cc:// URLs)').option('-p, --print [prompt]', 'Print mode (headless)').option('--output-format <format>', 'Output format: text, json, stream-json', 'text').action(async (ccUrl: string, opts: {
|
||||
program.command('open <cc-url>').description('Connect to an OpenClaude server (internal — use cc:// URLs)').option('-p, --print [prompt]', 'Print mode (headless)').option('--output-format <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<CommanderCommand> {
|
||||
serverUrl,
|
||||
authToken,
|
||||
cwd: getOriginalCwd(),
|
||||
dangerouslySkipPermissions: _pendingConnect?.dangerouslySkipPermissions
|
||||
dangerouslySkipPermissions: opts.dangerouslySkipPermissions ?? false
|
||||
});
|
||||
if (session.workDir) {
|
||||
setOriginalCwd(session.workDir);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'))
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 <m>). */
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
</WarningNoticeRow>;
|
||||
}
|
||||
};
|
||||
// `--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: () => <WarningNoticeRow>
|
||||
<Text color="warning">
|
||||
<Text bold>--dangerously-skip-permissions</Text> is active.
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
Every tool consent check is bypassed. Only use inside a sandbox with no internet access.
|
||||
Restart without the flag to re-enable prompts.
|
||||
</Text>
|
||||
</WarningNoticeRow>
|
||||
isActive: ctx => isDangerousMode(ctx.permissionMode),
|
||||
render: ctx => {
|
||||
const mode = ctx.permissionMode;
|
||||
const isFullAccess = isFullAccessMode(mode);
|
||||
return <WarningNoticeRow>
|
||||
<Text color="warning">
|
||||
<Text bold>{mode}</Text> mode is active.
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{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.'}
|
||||
</Text>
|
||||
</WarningNoticeRow>
|
||||
},
|
||||
};
|
||||
|
||||
// All notice definitions
|
||||
|
||||
Reference in New Issue
Block a user