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:
மனோஜ்குமார் பழனிச்சாமி
2026-08-19 13:52:52 +08:00
committed by GitHub
co-authored by Claude Fable 5
parent 294bd9a1df
commit 787f2a9390
19 changed files with 1130 additions and 290 deletions
+2 -2
View File
@@ -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
View File
@@ -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['"]\)/)
}
})
})
+1 -1
View File
@@ -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
View File
@@ -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);
+3 -3
View File
@@ -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
View File
@@ -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)
}
+51
View File
@@ -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'])
})
})
+40
View File
@@ -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))
}
+2 -2
View File
@@ -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
}
+2 -1
View File
@@ -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')
+31
View File
@@ -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)
}
})
})
+4 -2
View File
@@ -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;
}
+93
View File
@@ -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)
})
})
+151
View File
@@ -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
}
+2 -2
View File
@@ -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'))
)
+193
View File
@@ -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)
})
})
+174
View File
@@ -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
+44 -22
View File
@@ -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