pyclaudecli 1.0.2__tar.gz → 1.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyclaudecli
3
- Version: 1.0.2
3
+ Version: 1.0.3
4
4
  Summary: Python library wrapping the Claude Code CLI: prompts, background agents, auth, MCP, plugins, and more
5
5
  Author-email: Suriya Ravichandran <suriyaravichandran@itrendsolution.com>
6
6
  License: MIT
@@ -41,6 +41,7 @@ It's a thin wrapper, not a reimplementation — every call runs the real `claude
41
41
  - [Command-line usage](#command-line-usage)
42
42
  - [Errors](#errors)
43
43
  - [`build_flags`](#build_flags)
44
+ - [Security notes](#security-notes)
44
45
  - [Every method, with an example](#every-method-with-an-example)
45
46
  - [Development](#development)
46
47
  - [License](#license)
@@ -81,6 +82,15 @@ claude = ClaudeCLI(
81
82
  )
82
83
  ```
83
84
 
85
+ `env` is merged **over** the current environment, so `PATH`, `HOME` and the rest still
86
+ reach the CLI — passing one API key doesn't cost you the ability to find the binary or
87
+ its credentials. Pass `replace_env=True` if you really want the child to start from a
88
+ clean slate:
89
+
90
+ ```python
91
+ claude = ClaudeCLI(env={"PATH": "/usr/bin", "HOME": "/tmp/sandbox"}, replace_env=True)
92
+ ```
93
+
84
94
  ## Authentication
85
95
 
86
96
  `claude` handles auth itself, so `pyclaudecli` just drives it. You have two options.
@@ -410,13 +420,49 @@ gateway.terminate()
410
420
 
411
421
  See `ClaudeCLI`'s docstrings for the exact CLI flags behind each method — it covers every top-level `claude` command (`auth`, `mcp`, `plugin`, `project`, `agents`/background sessions, `auto-mode`, `doctor`, `update`, `install`, `import`, `ultrareview`, `gateway`) plus the main prompt flags. Anything not exposed as a named parameter can still be passed through via each method's `extra_flags` dict.
412
422
 
423
+ ## Security notes
424
+
425
+ A few things the wrapper does on your behalf, worth knowing if you're feeding it input
426
+ from anywhere but your own code:
427
+
428
+ **Prompts can't smuggle in CLI flags.** `prompt()`, `prompt_stream()`, `start_background()`
429
+ and the `pyclaudecli` command put `--` between the options and your text, so a prompt that
430
+ starts with a dash is text, not a flag. Without it, user-supplied input like
431
+ `--dangerously-skip-permissions` or `--settings /tmp/evil.json` would be parsed by the CLI:
432
+
433
+ ```python
434
+ claude.prompt("--version") # asks Claude about "--version"; does not run the flag
435
+ ```
436
+
437
+ Flags you actually want still go through named parameters or `extra_flags`. Note this
438
+ protection covers the prompt text — if you interpolate untrusted input into a *flag value*
439
+ (`model=`, `settings=`, `add_dir=`), validate it yourself.
440
+
441
+ **Errors don't spill credentials.** `ClaudeCLIError.args`/`.cmd` and the exception message
442
+ are redacted before they're raised, so MCP auth headers, injected `--env` values, tokens
443
+ inside an `mcp add-json` payload and anything shaped like `sk-ant-…`, `Bearer …` or
444
+ `password=…` come back as `<redacted>`. Flag names survive so the command is still
445
+ recognisable, and long values are truncated. Tracebacks and log aggregators get the
446
+ redacted form; `exc.stdout`/`exc.stderr` still hold the raw output for local debugging.
447
+
448
+ **Login codes are handled as single-use secrets.** `auth_login()` writes exactly one line
449
+ to the CLI's stdin — a code containing a newline can't inject extra input — and drops the
450
+ code and the captured output from memory once it's submitted. The child process and its
451
+ pipes are always cleaned up, including when your `code_provider` raises.
452
+
453
+ Two things it deliberately does *not* do: it never runs a shell (every call is an argv
454
+ list, so there's no shell-injection surface), and it doesn't manage credentials itself —
455
+ auth lives with the `claude` CLI and your environment. Remember the argv of a running
456
+ process is visible to other users on the same machine via `ps`, so prefer env vars over
457
+ flags for anything sensitive.
458
+
413
459
  ## Development
414
460
 
415
461
  ```bash
416
462
  git clone https://github.com/Suriya-Ravichandran/pyclaudecli.git
417
463
  cd pyclaudecli
418
464
  pip install -e .
419
- pytest
465
+ pytest # offline; does not invoke the claude binary
420
466
  ```
421
467
 
422
468
  ## License
@@ -18,6 +18,7 @@ It's a thin wrapper, not a reimplementation — every call runs the real `claude
18
18
  - [Command-line usage](#command-line-usage)
19
19
  - [Errors](#errors)
20
20
  - [`build_flags`](#build_flags)
21
+ - [Security notes](#security-notes)
21
22
  - [Every method, with an example](#every-method-with-an-example)
22
23
  - [Development](#development)
23
24
  - [License](#license)
@@ -58,6 +59,15 @@ claude = ClaudeCLI(
58
59
  )
59
60
  ```
60
61
 
62
+ `env` is merged **over** the current environment, so `PATH`, `HOME` and the rest still
63
+ reach the CLI — passing one API key doesn't cost you the ability to find the binary or
64
+ its credentials. Pass `replace_env=True` if you really want the child to start from a
65
+ clean slate:
66
+
67
+ ```python
68
+ claude = ClaudeCLI(env={"PATH": "/usr/bin", "HOME": "/tmp/sandbox"}, replace_env=True)
69
+ ```
70
+
61
71
  ## Authentication
62
72
 
63
73
  `claude` handles auth itself, so `pyclaudecli` just drives it. You have two options.
@@ -387,13 +397,49 @@ gateway.terminate()
387
397
 
388
398
  See `ClaudeCLI`'s docstrings for the exact CLI flags behind each method — it covers every top-level `claude` command (`auth`, `mcp`, `plugin`, `project`, `agents`/background sessions, `auto-mode`, `doctor`, `update`, `install`, `import`, `ultrareview`, `gateway`) plus the main prompt flags. Anything not exposed as a named parameter can still be passed through via each method's `extra_flags` dict.
389
399
 
400
+ ## Security notes
401
+
402
+ A few things the wrapper does on your behalf, worth knowing if you're feeding it input
403
+ from anywhere but your own code:
404
+
405
+ **Prompts can't smuggle in CLI flags.** `prompt()`, `prompt_stream()`, `start_background()`
406
+ and the `pyclaudecli` command put `--` between the options and your text, so a prompt that
407
+ starts with a dash is text, not a flag. Without it, user-supplied input like
408
+ `--dangerously-skip-permissions` or `--settings /tmp/evil.json` would be parsed by the CLI:
409
+
410
+ ```python
411
+ claude.prompt("--version") # asks Claude about "--version"; does not run the flag
412
+ ```
413
+
414
+ Flags you actually want still go through named parameters or `extra_flags`. Note this
415
+ protection covers the prompt text — if you interpolate untrusted input into a *flag value*
416
+ (`model=`, `settings=`, `add_dir=`), validate it yourself.
417
+
418
+ **Errors don't spill credentials.** `ClaudeCLIError.args`/`.cmd` and the exception message
419
+ are redacted before they're raised, so MCP auth headers, injected `--env` values, tokens
420
+ inside an `mcp add-json` payload and anything shaped like `sk-ant-…`, `Bearer …` or
421
+ `password=…` come back as `<redacted>`. Flag names survive so the command is still
422
+ recognisable, and long values are truncated. Tracebacks and log aggregators get the
423
+ redacted form; `exc.stdout`/`exc.stderr` still hold the raw output for local debugging.
424
+
425
+ **Login codes are handled as single-use secrets.** `auth_login()` writes exactly one line
426
+ to the CLI's stdin — a code containing a newline can't inject extra input — and drops the
427
+ code and the captured output from memory once it's submitted. The child process and its
428
+ pipes are always cleaned up, including when your `code_provider` raises.
429
+
430
+ Two things it deliberately does *not* do: it never runs a shell (every call is an argv
431
+ list, so there's no shell-injection surface), and it doesn't manage credentials itself —
432
+ auth lives with the `claude` CLI and your environment. Remember the argv of a running
433
+ process is visible to other users on the same machine via `ps`, so prefer env vars over
434
+ flags for anything sensitive.
435
+
390
436
  ## Development
391
437
 
392
438
  ```bash
393
439
  git clone https://github.com/Suriya-Ravichandran/pyclaudecli.git
394
440
  cd pyclaudecli
395
441
  pip install -e .
396
- pytest
442
+ pytest # offline; does not invoke the claude binary
397
443
  ```
398
444
 
399
445
  ## License
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pyclaudecli"
7
- version = "1.0.2"
7
+ version = "1.0.3"
8
8
  description = "Python library wrapping the Claude Code CLI: prompts, background agents, auth, MCP, plugins, and more"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -3,7 +3,7 @@
3
3
  from .client import ClaudeCLI, build_flags
4
4
  from .exceptions import ClaudeCLIError, ClaudeNotFoundError, ClaudeTimeoutError
5
5
 
6
- __version__ = "1.0.2"
6
+ __version__ = "1.0.3"
7
7
 
8
8
  __all__ = [
9
9
  "ClaudeCLI",
@@ -45,7 +45,9 @@ def main(argv: Optional[List[str]] = None) -> int:
45
45
  model, prompt = parse_args(argv)
46
46
  client = ClaudeCLI()
47
47
  try:
48
- result = client.run(["--print", "--model", model, prompt], check=False)
48
+ # `--` keeps a prompt that starts with a dash from being parsed as
49
+ # flags by the CLI (this parser passes unknown args through as text).
50
+ result = client.run(["--print", "--model", model, "--", prompt], check=False)
49
51
  except Exception as exc: # ClaudeNotFoundError, etc.
50
52
  print(str(exc), file=sys.stderr)
51
53
  return 1
@@ -0,0 +1,318 @@
1
+ """Low-level process plumbing shared by ClaudeCLI's methods."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import select
8
+ import subprocess
9
+ import time
10
+ from dataclasses import dataclass
11
+ from typing import Callable, Iterator, Optional, Sequence
12
+
13
+ from .exceptions import ClaudeCLIError, ClaudeNotFoundError, ClaudeTimeoutError
14
+
15
+ _URL_PATTERN = re.compile(r"https://\S+")
16
+
17
+ # Flags whose values are credentials (MCP auth headers, injected env vars).
18
+ _SECRET_FLAGS = frozenset({"--header", "--env", "--client-id", "--client-secret"})
19
+
20
+ # Credential shapes that can turn up anywhere in an argv, including inside the
21
+ # JSON blob passed to `mcp add-json`.
22
+ _SECRET_VALUE_PATTERN = re.compile(
23
+ r"""(
24
+ sk-ant-[\w-]+ # Anthropic API keys
25
+ | pypi-[\w-]+ # PyPI tokens
26
+ | gh[pousr]_[A-Za-z0-9]+ # GitHub tokens
27
+ | [Bb]earer\s+\S+ # bearer tokens
28
+ | (?i:[\w.-]*(?:token|secret|key|password|passwd|auth)[\w.-]*)
29
+ \s*["\s]*[=:]\s*"?[^\s"',}]+ # anything token/secret/key-ish = value
30
+ )""",
31
+ re.VERBOSE,
32
+ )
33
+
34
+ _REDACTED = "<redacted>"
35
+ _MAX_ARG_LEN = 120
36
+ _MAX_DETAIL_LEN = 2000
37
+ _MAX_LOGIN_BUFFER = 64 * 1024
38
+
39
+
40
+ def redact_arg(value: str, limit: Optional[int] = _MAX_ARG_LEN) -> str:
41
+ """Masks credential-looking substrings, and truncates very long values."""
42
+ cleaned = _SECRET_VALUE_PATTERN.sub(_REDACTED, str(value))
43
+ if limit is not None and len(cleaned) > limit:
44
+ cleaned = cleaned[:limit] + "…"
45
+ return cleaned
46
+
47
+
48
+ def redact_command(command: Sequence[str]) -> list:
49
+ """Returns a copy of an argv safe to put in an error message or a log.
50
+
51
+ Values of credential-bearing flags are replaced wholesale; everything else
52
+ is scanned for embedded secrets and truncated. Flag *names* survive, so the
53
+ command stays recognisable when debugging.
54
+ """
55
+ safe = []
56
+ masking = False
57
+ for token in command:
58
+ token = str(token)
59
+ if token.startswith("-"):
60
+ masking = token in _SECRET_FLAGS
61
+ safe.append(token)
62
+ continue
63
+ safe.append(_REDACTED if masking else redact_arg(token))
64
+ return safe
65
+
66
+
67
+ def format_command(command: Sequence[str]) -> str:
68
+ """A redacted, shell-ish rendering of an argv for error messages."""
69
+ return " ".join(redact_command(command))
70
+
71
+
72
+ @dataclass
73
+ class CommandResult:
74
+ args: list
75
+ returncode: int
76
+ stdout: str
77
+ stderr: str
78
+
79
+ @property
80
+ def ok(self) -> bool:
81
+ return self.returncode == 0
82
+
83
+
84
+ def run(
85
+ binary: str,
86
+ args: Sequence[str],
87
+ *,
88
+ cwd: Optional[str] = None,
89
+ env: Optional[dict] = None,
90
+ input_text: Optional[str] = None,
91
+ timeout: Optional[float] = None,
92
+ check: bool = True,
93
+ ) -> CommandResult:
94
+ command = [binary, *args]
95
+ try:
96
+ completed = subprocess.run(
97
+ command,
98
+ cwd=cwd,
99
+ env=env,
100
+ input=input_text,
101
+ capture_output=True,
102
+ text=True,
103
+ timeout=timeout,
104
+ )
105
+ except FileNotFoundError as exc:
106
+ raise ClaudeNotFoundError(
107
+ f"'{binary}' was not found on PATH. Is Claude Code installed?",
108
+ cmd=redact_command(command),
109
+ ) from exc
110
+ except subprocess.TimeoutExpired as exc:
111
+ stdout = exc.stdout.decode() if isinstance(exc.stdout, bytes) else (exc.stdout or "")
112
+ stderr = exc.stderr.decode() if isinstance(exc.stderr, bytes) else (exc.stderr or "")
113
+ raise ClaudeTimeoutError(
114
+ f"'{format_command(command)}' did not finish within {timeout}s",
115
+ cmd=redact_command(command),
116
+ stdout=stdout,
117
+ stderr=stderr,
118
+ ) from exc
119
+
120
+ result = CommandResult(command, completed.returncode, completed.stdout, completed.stderr)
121
+ if check and not result.ok:
122
+ detail = redact_arg((result.stderr or result.stdout or "").strip(), limit=_MAX_DETAIL_LEN)
123
+ raise ClaudeCLIError(
124
+ f"'{format_command(command)}' exited with {result.returncode}: {detail}",
125
+ returncode=result.returncode,
126
+ stdout=result.stdout,
127
+ stderr=result.stderr,
128
+ cmd=redact_command(command),
129
+ )
130
+ return result
131
+
132
+
133
+ def stream_lines(
134
+ binary: str,
135
+ args: Sequence[str],
136
+ *,
137
+ cwd: Optional[str] = None,
138
+ env: Optional[dict] = None,
139
+ ) -> Iterator[str]:
140
+ """Runs a command and yields decoded stdout lines as they arrive."""
141
+ command = [binary, *args]
142
+ try:
143
+ proc = subprocess.Popen(
144
+ command,
145
+ cwd=cwd,
146
+ env=env,
147
+ stdout=subprocess.PIPE,
148
+ stderr=subprocess.STDOUT,
149
+ text=True,
150
+ bufsize=1,
151
+ )
152
+ except FileNotFoundError as exc:
153
+ raise ClaudeNotFoundError(
154
+ f"'{binary}' was not found on PATH. Is Claude Code installed?",
155
+ cmd=redact_command(command),
156
+ ) from exc
157
+
158
+ assert proc.stdout is not None
159
+ try:
160
+ for line in proc.stdout:
161
+ yield line.rstrip("\n")
162
+ finally:
163
+ proc.stdout.close()
164
+ returncode = proc.wait()
165
+ if returncode != 0:
166
+ raise ClaudeCLIError(
167
+ f"'{format_command(command)}' exited with {returncode}",
168
+ returncode=returncode,
169
+ cmd=redact_command(command),
170
+ )
171
+
172
+
173
+ def run_interactive(
174
+ binary: str,
175
+ args: Sequence[str],
176
+ *,
177
+ cwd: Optional[str] = None,
178
+ env: Optional[dict] = None,
179
+ ) -> int:
180
+ """Runs a command with stdio inherited from the current process.
181
+
182
+ For subcommands that need a real terminal (attach, setup-token, an
183
+ interactive mcp/import picker) rather than captured output.
184
+ """
185
+ command = [binary, *args]
186
+ try:
187
+ return subprocess.call(command, cwd=cwd, env=env)
188
+ except FileNotFoundError as exc:
189
+ raise ClaudeNotFoundError(
190
+ f"'{binary}' was not found on PATH. Is Claude Code installed?",
191
+ cmd=redact_command(command),
192
+ ) from exc
193
+
194
+
195
+ def oauth_login(
196
+ binary: str,
197
+ args: Sequence[str],
198
+ *,
199
+ cwd: Optional[str] = None,
200
+ env: Optional[dict] = None,
201
+ code: Optional[str] = None,
202
+ code_provider: Optional[Callable[[str], str]] = None,
203
+ on_output: Optional[Callable[[str], None]] = None,
204
+ prompt_marker: str = "Paste code here",
205
+ timeout: float = 180,
206
+ ) -> int:
207
+ """Drives an interactive OAuth login (`claude auth login` / `mcp login`).
208
+
209
+ Streams the child's output (forwarding it to `on_output`, if given) until
210
+ it prints its "paste the code" prompt, then writes back `code` — or
211
+ whatever `code_provider(url)` returns, where `url` is the login URL
212
+ found in the output so far. Falls back to `input()` if neither is given.
213
+ Returns the child's exit code.
214
+ """
215
+ command = [binary, *args]
216
+ try:
217
+ proc = subprocess.Popen(
218
+ command,
219
+ cwd=cwd,
220
+ env=env,
221
+ stdin=subprocess.PIPE,
222
+ stdout=subprocess.PIPE,
223
+ stderr=subprocess.STDOUT,
224
+ text=True,
225
+ bufsize=1,
226
+ )
227
+ except FileNotFoundError as exc:
228
+ raise ClaudeNotFoundError(
229
+ f"'{binary}' was not found on PATH. Is Claude Code installed?",
230
+ cmd=redact_command(command),
231
+ ) from exc
232
+
233
+ assert proc.stdout is not None and proc.stdin is not None
234
+ fd = proc.stdout.fileno()
235
+ buffer = ""
236
+ url: Optional[str] = None
237
+ deadline = time.time() + timeout
238
+
239
+ try:
240
+ while time.time() < deadline and proc.poll() is None:
241
+ ready, _, _ = select.select([fd], [], [], 0.5)
242
+ if not ready:
243
+ continue
244
+
245
+ chunk = os.read(fd, 4096).decode(errors="replace")
246
+ if not chunk:
247
+ break
248
+
249
+ buffer += chunk
250
+ # A login that never reaches its prompt must not grow the buffer
251
+ # without bound; the marker and URL both live near the tail.
252
+ if len(buffer) > _MAX_LOGIN_BUFFER:
253
+ buffer = buffer[-_MAX_LOGIN_BUFFER:]
254
+ if on_output:
255
+ on_output(chunk)
256
+
257
+ if url is None:
258
+ match = _URL_PATTERN.search(buffer)
259
+ if match:
260
+ url = match.group(0)
261
+
262
+ if prompt_marker in buffer:
263
+ if code is not None:
264
+ entered_code = code
265
+ elif code_provider is not None:
266
+ entered_code = code_provider(url or "")
267
+ else:
268
+ entered_code = input("\nPaste the code from the browser here: ")
269
+
270
+ # Only ever hand the child a single line: a code carrying a
271
+ # newline would otherwise write extra lines into its stdin.
272
+ entered_code = str(entered_code or "").splitlines()
273
+ entered_code = entered_code[0].strip() if entered_code else ""
274
+
275
+ proc.stdin.write(entered_code + "\n")
276
+ proc.stdin.flush()
277
+ proc.stdin.close()
278
+ # Drop the code and the captured output rather than holding
279
+ # them in memory for the rest of the call.
280
+ entered_code = ""
281
+ buffer = ""
282
+ break
283
+ else:
284
+ if proc.poll() is None:
285
+ raise ClaudeTimeoutError(
286
+ f"'{format_command(command)}' did not produce a login prompt within {timeout}s",
287
+ cmd=redact_command(command),
288
+ )
289
+
290
+ drain_deadline = time.time() + 30
291
+ while proc.poll() is None and time.time() < drain_deadline:
292
+ ready, _, _ = select.select([fd], [], [], 0.5)
293
+ if ready:
294
+ chunk = os.read(fd, 4096).decode(errors="replace")
295
+ if not chunk:
296
+ break
297
+ if on_output:
298
+ on_output(chunk)
299
+
300
+ if proc.poll() is None:
301
+ raise ClaudeTimeoutError(
302
+ f"'{format_command(command)}' did not finish after the code was submitted",
303
+ cmd=redact_command(command),
304
+ )
305
+
306
+ return proc.returncode
307
+ finally:
308
+ # Never leave a half-driven login (or its pipes) behind, whatever
309
+ # went wrong — including an exception raised by `code_provider`.
310
+ if proc.poll() is None:
311
+ proc.kill()
312
+ proc.wait()
313
+ for stream in (proc.stdin, proc.stdout):
314
+ try:
315
+ if stream is not None and not stream.closed:
316
+ stream.close()
317
+ except (OSError, ValueError):
318
+ pass
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import os
6
7
  import re
7
8
  from typing import Any, Callable, Dict, Iterator, List, Optional, Sequence, Union
8
9
 
@@ -58,14 +59,33 @@ class ClaudeCLI:
58
59
  cwd: Optional[str] = None,
59
60
  env: Optional[dict] = None,
60
61
  timeout: Optional[float] = None,
62
+ replace_env: bool = False,
61
63
  ) -> None:
62
64
  self.binary = binary
63
65
  self.cwd = cwd
64
66
  self.env = env
65
67
  self.timeout = timeout
68
+ self.replace_env = replace_env
66
69
 
67
70
  # -- internals -----------------------------------------------------
68
71
 
72
+ def _env(self) -> Optional[dict]:
73
+ """The environment to hand the child process.
74
+
75
+ `env` is merged *over* the current environment by default. Replacing it
76
+ outright would drop PATH (so the binary may not resolve, or a different
77
+ one could), HOME and XDG_* (so `claude` would look for its credentials
78
+ and settings somewhere else), which is rarely what a caller passing a
79
+ single API key intends. Pass `replace_env=True` for a clean slate.
80
+ """
81
+ if self.env is None:
82
+ return None
83
+ if self.replace_env:
84
+ return {str(k): str(v) for k, v in self.env.items()}
85
+ merged = os.environ.copy()
86
+ merged.update({str(k): str(v) for k, v in self.env.items()})
87
+ return merged
88
+
69
89
  def run(
70
90
  self,
71
91
  args: Sequence[str],
@@ -79,7 +99,7 @@ class ClaudeCLI:
79
99
  self.binary,
80
100
  args,
81
101
  cwd=self.cwd,
82
- env=self.env,
102
+ env=self._env(),
83
103
  input_text=input_text,
84
104
  timeout=timeout if timeout is not None else self.timeout,
85
105
  check=check,
@@ -93,17 +113,18 @@ class ClaudeCLI:
93
113
  try:
94
114
  return json.loads(result.stdout)
95
115
  except json.JSONDecodeError as exc:
116
+ command = [self.binary, *args]
96
117
  raise ClaudeCLIError(
97
- f"Expected JSON from `{self.binary} {' '.join(args)}`, "
98
- f"got: {result.stdout[:200]!r}",
118
+ f"Expected JSON from `{_process.format_command(command)}`, "
119
+ f"got: {_process.redact_arg(result.stdout[:200])!r}",
99
120
  returncode=result.returncode,
100
121
  stdout=result.stdout,
101
122
  stderr=result.stderr,
102
- cmd=[self.binary, *args],
123
+ cmd=_process.redact_command(command),
103
124
  ) from exc
104
125
 
105
126
  def _interactive(self, args: Sequence[str]) -> int:
106
- return _process.run_interactive(self.binary, args, cwd=self.cwd, env=self.env)
127
+ return _process.run_interactive(self.binary, args, cwd=self.cwd, env=self._env())
107
128
 
108
129
  # -- version / health ------------------------------------------------
109
130
 
@@ -197,7 +218,10 @@ class ClaudeCLI:
197
218
  args = ["--print"] + build_flags(options)
198
219
  if continue_session:
199
220
  args.append("--continue")
200
- args.append(text)
221
+ # `--` ends option parsing: without it a prompt that happens to start
222
+ # with a dash is read as CLI flags, so untrusted text could turn on
223
+ # things like --dangerously-skip-permissions.
224
+ args += ["--", text]
201
225
 
202
226
  result = self.run(args, input_text=input_text, timeout=timeout)
203
227
  if output_format == "json":
@@ -217,8 +241,8 @@ class ClaudeCLI:
217
241
  extra_flags["verbose"] = True # required by the CLI for --print + stream-json
218
242
 
219
243
  options = {k: v for k, v in extra_flags.items() if v is not None and v is not False}
220
- args = ["--print"] + build_flags(options) + [text]
221
- for line in _process.stream_lines(self.binary, args, cwd=self.cwd, env=self.env):
244
+ args = ["--print"] + build_flags(options) + ["--", text]
245
+ for line in _process.stream_lines(self.binary, args, cwd=self.cwd, env=self._env()):
222
246
  line = line.strip()
223
247
  if line:
224
248
  yield json.loads(line)
@@ -237,7 +261,7 @@ class ClaudeCLI:
237
261
  """`claude --bg <task>`; returns the short session id it prints."""
238
262
  options = dict(model=model, name=name, resume=resume)
239
263
  options.update(extra_flags or {})
240
- args = ["--bg"] + build_flags(options) + [task]
264
+ args = ["--bg"] + build_flags(options) + ["--", task]
241
265
  output = self._text(args)
242
266
  match = re.search(r"backgrounded\W*([0-9a-fA-F]+)", output)
243
267
  if not match:
@@ -314,7 +338,7 @@ class ClaudeCLI:
314
338
  self.binary,
315
339
  args,
316
340
  cwd=self.cwd,
317
- env=self.env,
341
+ env=self._env(),
318
342
  code=code,
319
343
  code_provider=code_provider,
320
344
  on_output=on_output or (lambda chunk: print(chunk, end="", flush=True)),
@@ -578,4 +602,4 @@ class ClaudeCLI:
578
602
  import subprocess
579
603
 
580
604
  args = [self.binary, "gateway", *build_flags({"config": config})]
581
- return subprocess.Popen(args, cwd=self.cwd, env=self.env)
605
+ return subprocess.Popen(args, cwd=self.cwd, env=self._env())
@@ -0,0 +1,159 @@
1
+ """Regression tests for the hardening in 1.0.3 — all offline, no `claude` needed."""
2
+
3
+ import os
4
+
5
+ import pytest
6
+
7
+ from pyclaudecli import ClaudeCLI, build_flags
8
+ from pyclaudecli._process import format_command, redact_arg, redact_command
9
+
10
+
11
+ class RecordingCLI(ClaudeCLI):
12
+ """Captures the argv a method would run instead of executing it."""
13
+
14
+ def __init__(self, **kwargs):
15
+ super().__init__(**kwargs)
16
+ self.calls = []
17
+
18
+ def run(self, args, **kwargs):
19
+ self.calls.append(list(args))
20
+ raise AssertionError("not executed")
21
+
22
+
23
+ def argv_for(method, *args, **kwargs):
24
+ client = RecordingCLI()
25
+ with pytest.raises(AssertionError):
26
+ getattr(client, method)(*args, **kwargs)
27
+ return client.calls[-1]
28
+
29
+
30
+ # -- argument injection ---------------------------------------------------
31
+
32
+ def test_prompt_separates_options_from_text():
33
+ argv = argv_for("prompt", "--version")
34
+ assert argv[-2:] == ["--", "--version"]
35
+
36
+
37
+ def test_prompt_separator_comes_after_continue():
38
+ argv = argv_for("prompt", "-h", continue_session=True)
39
+ assert argv.index("--continue") < argv.index("--")
40
+
41
+
42
+ @pytest.mark.parametrize(
43
+ "hostile",
44
+ ["--version", "--dangerously-skip-permissions", "-p", "--settings=/tmp/evil.json"],
45
+ )
46
+ def test_hostile_prompts_stay_positional(hostile):
47
+ argv = argv_for("prompt", hostile)
48
+ assert argv[-1] == hostile
49
+ assert argv[argv.index("--") + 1] == hostile
50
+
51
+
52
+ def test_start_background_separates_task():
53
+ argv = argv_for("start_background", "--version")
54
+ assert argv[-2:] == ["--", "--version"]
55
+
56
+
57
+ def test_module_entrypoint_separates_prompt():
58
+ from pyclaudecli.__main__ import main
59
+
60
+ captured = {}
61
+
62
+ class Fake(ClaudeCLI):
63
+ def run(self, args, **kwargs):
64
+ captured["argv"] = list(args)
65
+ raise SystemExit(0)
66
+
67
+ import pyclaudecli.__main__ as entry
68
+
69
+ original = entry.ClaudeCLI
70
+ entry.ClaudeCLI = Fake
71
+ try:
72
+ with pytest.raises(SystemExit):
73
+ main(["--version"])
74
+ finally:
75
+ entry.ClaudeCLI = original
76
+
77
+ assert captured["argv"][-2:] == ["--", "--version"]
78
+
79
+
80
+ # -- secret redaction -----------------------------------------------------
81
+
82
+ def test_redacts_values_of_credential_flags():
83
+ argv = ["claude", "mcp", "add", "--header", "Authorization: Bearer tok123", "s", "https://x"]
84
+ safe = redact_command(argv)
85
+ assert "tok123" not in " ".join(safe)
86
+ assert "--header" in safe # flag names stay, for debuggability
87
+
88
+
89
+ def test_redacts_env_flag_values():
90
+ safe = format_command(["claude", "mcp", "add", "--env", "API_KEY=supersecret", "s", "cmd"])
91
+ assert "supersecret" not in safe
92
+
93
+
94
+ @pytest.mark.parametrize(
95
+ "secret",
96
+ [
97
+ "sk-ant-api03-abcdef123456",
98
+ "pypi-AgEIcHlwaS5vcmcXYZ",
99
+ "ghp_abcdefghijklmnop",
100
+ '{"token": "abcdef123456"}',
101
+ "password=hunter2",
102
+ ],
103
+ )
104
+ def test_redacts_credential_shapes_anywhere(secret):
105
+ assert secret not in redact_arg(secret)
106
+ assert "<redacted>" in redact_arg(secret)
107
+
108
+
109
+ def test_add_json_payload_is_redacted():
110
+ payload = '{"type":"http","url":"https://x","headers":{"Authorization":"Bearer sk-ant-abc123"}}'
111
+ assert "sk-ant-abc123" not in format_command(["claude", "mcp", "add-json", "s", payload])
112
+
113
+
114
+ def test_long_arguments_are_truncated():
115
+ assert len(redact_arg("a" * 5000)) < 200
116
+
117
+
118
+ def test_ordinary_arguments_survive():
119
+ argv = ["claude", "--print", "--model", "haiku", "--", "Summarize this repo"]
120
+ assert redact_command(argv) == argv
121
+
122
+
123
+ # -- environment handling -------------------------------------------------
124
+
125
+ def test_env_is_merged_over_os_environ_by_default():
126
+ env = ClaudeCLI(env={"ANTHROPIC_API_KEY": "sk-ant-x"})._env()
127
+ assert env["ANTHROPIC_API_KEY"] == "sk-ant-x"
128
+ assert env.get("PATH") == os.environ.get("PATH")
129
+
130
+
131
+ def test_replace_env_gives_a_clean_slate():
132
+ env = ClaudeCLI(env={"ANTHROPIC_API_KEY": "sk-ant-x"}, replace_env=True)._env()
133
+ assert env == {"ANTHROPIC_API_KEY": "sk-ant-x"}
134
+
135
+
136
+ def test_no_env_means_inherit():
137
+ assert ClaudeCLI()._env() is None
138
+
139
+
140
+ # -- unchanged behaviour --------------------------------------------------
141
+
142
+ def test_build_flags_still_builds_flags():
143
+ assert build_flags({"model": "haiku", "verbose": True, "quiet": False, "add_dir": ["a", "b"]}) == [
144
+ "--model", "haiku", "--verbose", "--add-dir", "a", "b",
145
+ ]
146
+
147
+
148
+ def test_cli_stderr_is_redacted_in_error_messages():
149
+ from pyclaudecli._process import redact_arg
150
+
151
+ stderr = 'invalid header "Authorization: Bearer sk-ant-leak123"'
152
+ assert "sk-ant-leak123" not in redact_arg(stderr, limit=2000)
153
+
154
+
155
+ def test_detail_redaction_keeps_long_errors_readable():
156
+ from pyclaudecli._process import redact_arg
157
+
158
+ long_error = "error: " + ("context " * 100)
159
+ assert len(redact_arg(long_error, limit=2000)) > 500
@@ -0,0 +1,8 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.8"
4
+
5
+ [[package]]
6
+ name = "pyclaudecli"
7
+ version = "1.0.3"
8
+ source = { editable = "." }
@@ -1,241 +0,0 @@
1
- """Low-level process plumbing shared by ClaudeCLI's methods."""
2
-
3
- from __future__ import annotations
4
-
5
- import os
6
- import re
7
- import select
8
- import subprocess
9
- import time
10
- from dataclasses import dataclass
11
- from typing import Callable, Iterator, Optional, Sequence
12
-
13
- from .exceptions import ClaudeCLIError, ClaudeNotFoundError, ClaudeTimeoutError
14
-
15
- _URL_PATTERN = re.compile(r"https://\S+")
16
-
17
-
18
- @dataclass
19
- class CommandResult:
20
- args: list
21
- returncode: int
22
- stdout: str
23
- stderr: str
24
-
25
- @property
26
- def ok(self) -> bool:
27
- return self.returncode == 0
28
-
29
-
30
- def run(
31
- binary: str,
32
- args: Sequence[str],
33
- *,
34
- cwd: Optional[str] = None,
35
- env: Optional[dict] = None,
36
- input_text: Optional[str] = None,
37
- timeout: Optional[float] = None,
38
- check: bool = True,
39
- ) -> CommandResult:
40
- command = [binary, *args]
41
- try:
42
- completed = subprocess.run(
43
- command,
44
- cwd=cwd,
45
- env=env,
46
- input=input_text,
47
- capture_output=True,
48
- text=True,
49
- timeout=timeout,
50
- )
51
- except FileNotFoundError as exc:
52
- raise ClaudeNotFoundError(
53
- f"'{binary}' was not found on PATH. Is Claude Code installed?",
54
- cmd=command,
55
- ) from exc
56
- except subprocess.TimeoutExpired as exc:
57
- stdout = exc.stdout.decode() if isinstance(exc.stdout, bytes) else (exc.stdout or "")
58
- stderr = exc.stderr.decode() if isinstance(exc.stderr, bytes) else (exc.stderr or "")
59
- raise ClaudeTimeoutError(
60
- f"'{' '.join(command)}' did not finish within {timeout}s",
61
- cmd=command,
62
- stdout=stdout,
63
- stderr=stderr,
64
- ) from exc
65
-
66
- result = CommandResult(command, completed.returncode, completed.stdout, completed.stderr)
67
- if check and not result.ok:
68
- detail = (result.stderr or result.stdout or "").strip()
69
- raise ClaudeCLIError(
70
- f"'{' '.join(command)}' exited with {result.returncode}: {detail}",
71
- returncode=result.returncode,
72
- stdout=result.stdout,
73
- stderr=result.stderr,
74
- cmd=command,
75
- )
76
- return result
77
-
78
-
79
- def stream_lines(
80
- binary: str,
81
- args: Sequence[str],
82
- *,
83
- cwd: Optional[str] = None,
84
- env: Optional[dict] = None,
85
- ) -> Iterator[str]:
86
- """Runs a command and yields decoded stdout lines as they arrive."""
87
- command = [binary, *args]
88
- try:
89
- proc = subprocess.Popen(
90
- command,
91
- cwd=cwd,
92
- env=env,
93
- stdout=subprocess.PIPE,
94
- stderr=subprocess.STDOUT,
95
- text=True,
96
- bufsize=1,
97
- )
98
- except FileNotFoundError as exc:
99
- raise ClaudeNotFoundError(
100
- f"'{binary}' was not found on PATH. Is Claude Code installed?",
101
- cmd=command,
102
- ) from exc
103
-
104
- assert proc.stdout is not None
105
- try:
106
- for line in proc.stdout:
107
- yield line.rstrip("\n")
108
- finally:
109
- proc.stdout.close()
110
- returncode = proc.wait()
111
- if returncode != 0:
112
- raise ClaudeCLIError(
113
- f"'{' '.join(command)}' exited with {returncode}",
114
- returncode=returncode,
115
- cmd=command,
116
- )
117
-
118
-
119
- def run_interactive(
120
- binary: str,
121
- args: Sequence[str],
122
- *,
123
- cwd: Optional[str] = None,
124
- env: Optional[dict] = None,
125
- ) -> int:
126
- """Runs a command with stdio inherited from the current process.
127
-
128
- For subcommands that need a real terminal (attach, setup-token, an
129
- interactive mcp/import picker) rather than captured output.
130
- """
131
- command = [binary, *args]
132
- try:
133
- return subprocess.call(command, cwd=cwd, env=env)
134
- except FileNotFoundError as exc:
135
- raise ClaudeNotFoundError(
136
- f"'{binary}' was not found on PATH. Is Claude Code installed?",
137
- cmd=command,
138
- ) from exc
139
-
140
-
141
- def oauth_login(
142
- binary: str,
143
- args: Sequence[str],
144
- *,
145
- cwd: Optional[str] = None,
146
- env: Optional[dict] = None,
147
- code: Optional[str] = None,
148
- code_provider: Optional[Callable[[str], str]] = None,
149
- on_output: Optional[Callable[[str], None]] = None,
150
- prompt_marker: str = "Paste code here",
151
- timeout: float = 180,
152
- ) -> int:
153
- """Drives an interactive OAuth login (`claude auth login` / `mcp login`).
154
-
155
- Streams the child's output (forwarding it to `on_output`, if given) until
156
- it prints its "paste the code" prompt, then writes back `code` — or
157
- whatever `code_provider(url)` returns, where `url` is the login URL
158
- found in the output so far. Falls back to `input()` if neither is given.
159
- Returns the child's exit code.
160
- """
161
- command = [binary, *args]
162
- try:
163
- proc = subprocess.Popen(
164
- command,
165
- cwd=cwd,
166
- env=env,
167
- stdin=subprocess.PIPE,
168
- stdout=subprocess.PIPE,
169
- stderr=subprocess.STDOUT,
170
- text=True,
171
- bufsize=1,
172
- )
173
- except FileNotFoundError as exc:
174
- raise ClaudeNotFoundError(
175
- f"'{binary}' was not found on PATH. Is Claude Code installed?",
176
- cmd=command,
177
- ) from exc
178
-
179
- assert proc.stdout is not None and proc.stdin is not None
180
- fd = proc.stdout.fileno()
181
- buffer = ""
182
- url: Optional[str] = None
183
- deadline = time.time() + timeout
184
-
185
- while time.time() < deadline and proc.poll() is None:
186
- ready, _, _ = select.select([fd], [], [], 0.5)
187
- if not ready:
188
- continue
189
-
190
- chunk = os.read(fd, 4096).decode(errors="replace")
191
- if not chunk:
192
- break
193
-
194
- buffer += chunk
195
- if on_output:
196
- on_output(chunk)
197
-
198
- if url is None:
199
- match = _URL_PATTERN.search(buffer)
200
- if match:
201
- url = match.group(0)
202
-
203
- if prompt_marker in buffer:
204
- if code is not None:
205
- entered_code = code
206
- elif code_provider is not None:
207
- entered_code = code_provider(url or "")
208
- else:
209
- entered_code = input("\nPaste the code from the browser here: ").strip()
210
-
211
- proc.stdin.write(entered_code + "\n")
212
- proc.stdin.flush()
213
- proc.stdin.close()
214
- buffer = ""
215
- break
216
- else:
217
- if proc.poll() is None:
218
- proc.kill()
219
- raise ClaudeTimeoutError(
220
- f"'{' '.join(command)}' did not produce a login prompt within {timeout}s",
221
- cmd=command,
222
- )
223
-
224
- drain_deadline = time.time() + 30
225
- while proc.poll() is None and time.time() < drain_deadline:
226
- ready, _, _ = select.select([fd], [], [], 0.5)
227
- if ready:
228
- chunk = os.read(fd, 4096).decode(errors="replace")
229
- if not chunk:
230
- break
231
- if on_output:
232
- on_output(chunk)
233
-
234
- if proc.poll() is None:
235
- proc.kill()
236
- raise ClaudeTimeoutError(
237
- f"'{' '.join(command)}' did not finish after the code was submitted",
238
- cmd=command,
239
- )
240
-
241
- return proc.returncode
@@ -1,28 +0,0 @@
1
- from pyclaudecli import ClaudeCLI, ClaudeCLIError
2
-
3
- claude = ClaudeCLI() # add cwd=..., env=... if needed
4
-
5
- # Plain text answer
6
- claude.prompt("Explain this function", model="haiku")
7
-
8
- # Structured result: cost, session_id, etc.
9
- result = claude.prompt_json("2+2?", model="haiku")
10
- result["result"], result["total_cost_usd"]
11
-
12
- # Live token/event stream
13
- for event in claude.prompt_stream("Write a haiku"):
14
- print(event["type"])
15
-
16
- # Background agent (long-running task)
17
- sid = claude.start_background("Refactor auth module", model="sonnet")
18
- claude.list_agents()
19
- claude.logs(sid, strip_ansi=True)
20
- claude.stop(sid); claude.rm(sid)
21
-
22
- # Auth
23
- claude.auth_status()
24
- claude.auth_login() # prints URL, prompts you for the pasted code
25
-
26
- # MCP / plugins
27
- claude.mcp_add("sentry", "https://mcp.sentry.dev/mcp", transport="http")
28
- claude.plugin_install("some-plugin", yes=True)
File without changes
File without changes