From f4609bfea6952db1b69074341498a8521dbd36fd Mon Sep 17 00:00:00 2001 From: chen Date: Tue, 26 May 2026 00:35:38 +0800 Subject: [PATCH 1/5] fix: opt-in --debug flag and README security warning Replace hardcoded debug=True in app.run() with a --debug CLI flag (default False) so the server starts in production mode unless explicitly requested. Document the dangerous --host 0.0.0.0 plus --debug combination in the README Options section. --- README.md | 5 +++++ app.py | 8 +++++++- tests/test_cli_args.py | 21 +++++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 76827d3..f23db0a 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,11 @@ python app.py --port 8080 --host 0.0.0.0 python app.py --base-dir /path/to/claude/projects ``` +> **Security warning:** Do not use `--host 0.0.0.0` together with `--debug` on untrusted networks. +> That combination exposes Werkzeug's interactive debugger, which allows arbitrary code execution +> from any browser that can reach the server. For typical local browsing, keep the default +> `--host 127.0.0.1` and omit `--debug`. + ### CLI Export ```bash diff --git a/app.py b/app.py index cca3081..bd00b14 100644 --- a/app.py +++ b/app.py @@ -41,6 +41,12 @@ def index(): parser = argparse.ArgumentParser(description="Claude Code Chat Browser") parser.add_argument("--port", type=int, default=5000) parser.add_argument("--host", default="127.0.0.1") + parser.add_argument( + "--debug", + action="store_true", + default=False, + help="Enable Flask/Werkzeug debug mode (never use with --host 0.0.0.0 on untrusted networks).", + ) parser.add_argument("--base-dir", default=None, help="Override Claude projects dir") parser.add_argument( "--exclude-rules", "-e", @@ -56,6 +62,6 @@ def index(): app.run( host=args.host, port=args.port, - debug=True, + debug=args.debug, use_reloader=(sys.platform != "win32"), ) diff --git a/tests/test_cli_args.py b/tests/test_cli_args.py index 627e8c5..3478fca 100644 --- a/tests/test_cli_args.py +++ b/tests/test_cli_args.py @@ -221,6 +221,7 @@ def _build_parser(self) -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Claude Code Chat Browser") parser.add_argument("--port", type=int, default=5000) parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--debug", action="store_true", default=False) parser.add_argument("--base-dir", default=None) parser.add_argument("--exclude-rules", "-e", default=None, metavar="PATH", dest="exclude_rules") @@ -237,6 +238,26 @@ def test_host_override(self): args = parser.parse_args(["--host", "127.0.0.1"]) assert args.host == "127.0.0.1" + def test_debug_default_is_false(self): + parser = self._build_parser() + args = parser.parse_args([]) + assert args.debug is False + + def test_debug_explicit_true(self): + parser = self._build_parser() + args = parser.parse_args(["--debug"]) + assert args.debug is True + + def test_app_py_debug_not_hardcoded_true(self): + """app.run() must not pass debug=True unconditionally.""" + app_path = os.path.join(REPO_ROOT, "app.py") + with open(app_path, "r", encoding="utf-8") as f: + src = f.read() + run_block_start = src.find("app.run(") + assert run_block_start != -1 + run_block = src[run_block_start : src.find(")", run_block_start) + 1] + assert "debug=True" not in run_block + def test_port_default(self): parser = self._build_parser() args = parser.parse_args([]) From 6a2b2851fb5a62957d2ef4aff0ba72f97d8bb3ce Mon Sep 17 00:00:00 2001 From: chen Date: Tue, 26 May 2026 01:33:11 +0800 Subject: [PATCH 2/5] Fix: Replaced with AST helpers --- tests/test_cli_args.py | 52 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/tests/test_cli_args.py b/tests/test_cli_args.py index 3478fca..d0ed734 100644 --- a/tests/test_cli_args.py +++ b/tests/test_cli_args.py @@ -9,6 +9,7 @@ pytest tests/test_cli_args.py -v """ +import ast import sys import os import importlib @@ -23,6 +24,43 @@ from scripts.export import build_parser +def _is_app_run_call(node: ast.AST) -> bool: + if not isinstance(node, ast.Call): + return False + func = node.func + return ( + isinstance(func, ast.Attribute) + and func.attr == "run" + and isinstance(func.value, ast.Name) + and func.value.id == "app" + ) + + +def _call_passes_hardcoded_debug_true(call: ast.Call) -> bool: + """True if this Call passes literal True for debug (kwarg or positional).""" + for kw in call.keywords: + if kw.arg == "debug" and isinstance(kw.value, ast.Constant) and kw.value.value is True: + return True + for arg in call.args: + if isinstance(arg, ast.Constant) and arg.value is True: + return True + return False + + +def _debug_kwarg_uses_args(call: ast.Call) -> bool: + for kw in call.keywords: + if kw.arg != "debug": + continue + val = kw.value + return ( + isinstance(val, ast.Attribute) + and isinstance(val.value, ast.Name) + and val.value.id == "args" + and val.attr == "debug" + ) + return False + + # --------------------------------------------------------------------------- # export.py argument tests # --------------------------------------------------------------------------- @@ -249,14 +287,14 @@ def test_debug_explicit_true(self): assert args.debug is True def test_app_py_debug_not_hardcoded_true(self): - """app.run() must not pass debug=True unconditionally.""" + """app.run() must wire debug from args, not a literal True.""" app_path = os.path.join(REPO_ROOT, "app.py") - with open(app_path, "r", encoding="utf-8") as f: - src = f.read() - run_block_start = src.find("app.run(") - assert run_block_start != -1 - run_block = src[run_block_start : src.find(")", run_block_start) + 1] - assert "debug=True" not in run_block + with open(app_path, encoding="utf-8") as f: + tree = ast.parse(f.read(), filename=app_path) + app_run_calls = [n for n in ast.walk(tree) if _is_app_run_call(n)] + assert app_run_calls, "expected at least one app.run() call in app.py" + assert not any(_call_passes_hardcoded_debug_true(c) for c in app_run_calls) + assert any(_debug_kwarg_uses_args(c) for c in app_run_calls) def test_port_default(self): parser = self._build_parser() From 69c2a38370ea92aa9aaaced75e2140dc8b90e1c5 Mon Sep 17 00:00:00 2001 From: chen Date: Tue, 26 May 2026 01:52:27 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?shared=20CLI=20parser,=20reloader=20tied=20to=20--debug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 ++-- app.py | 12 ++++++-- tests/test_cli_args.py | 65 +++++++++++++++++++++++++----------------- 3 files changed, 51 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index f23db0a..c05e6bd 100644 --- a/README.md +++ b/README.md @@ -63,9 +63,9 @@ python app.py --base-dir /path/to/claude/projects ``` > **Security warning:** Do not use `--host 0.0.0.0` together with `--debug` on untrusted networks. -> That combination exposes Werkzeug's interactive debugger, which allows arbitrary code execution -> from any browser that can reach the server. For typical local browsing, keep the default -> `--host 127.0.0.1` and omit `--debug`. +> That combination exposes [Werkzeug's interactive debugger](https://werkzeug.palletsprojects.com/en/stable/debug/), +> which allows arbitrary code execution from any browser that can reach the server. +> For typical local browsing, keep the default `--host 127.0.0.1` and omit `--debug`. ### CLI Export diff --git a/app.py b/app.py index bd00b14..fcb25e5 100644 --- a/app.py +++ b/app.py @@ -35,7 +35,8 @@ def index(): return app -if __name__ == "__main__": +def build_cli_parser(): + """CLI argument parser for ``python app.py`` (stdlib only; safe to import in tests).""" import argparse parser = argparse.ArgumentParser(description="Claude Code Chat Browser") @@ -55,13 +56,18 @@ def index(): help="Path to exclusion rules file (sensitive sessions are omitted). " "If omitted, uses ~/.claude-code-chat-browser/exclusion-rules.txt if present.", ) - args = parser.parse_args() + return parser + + +if __name__ == "__main__": + args = build_cli_parser().parse_args() app = create_app(base_dir=args.base_dir, exclusion_rules_path=args.exclude_rules) print(f"Claude Code Chat Browser running at http://{args.host}:{args.port}") + # Reloader follows --debug on Unix only (Werkzeug file watcher, not the interactive debugger). app.run( host=args.host, port=args.port, debug=args.debug, - use_reloader=(sys.platform != "win32"), + use_reloader=args.debug and (sys.platform != "win32"), ) diff --git a/tests/test_cli_args.py b/tests/test_cli_args.py index d0ed734..2232c23 100644 --- a/tests/test_cli_args.py +++ b/tests/test_cli_args.py @@ -21,6 +21,7 @@ REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, REPO_ROOT) +from app import build_cli_parser from scripts.export import build_parser @@ -61,6 +62,25 @@ def _debug_kwarg_uses_args(call: ast.Call) -> bool: return False +def _ast_mentions_args_debug(node: ast.AST) -> bool: + for child in ast.walk(node): + if ( + isinstance(child, ast.Attribute) + and child.attr == "debug" + and isinstance(child.value, ast.Name) + and child.value.id == "args" + ): + return True + return False + + +def _use_reloader_kwarg_tied_to_debug(call: ast.Call) -> bool: + for kw in call.keywords: + if kw.arg == "use_reloader": + return _ast_mentions_args_debug(kw.value) + return False + + # --------------------------------------------------------------------------- # export.py argument tests # --------------------------------------------------------------------------- @@ -252,37 +272,26 @@ def test_help_exits_zero(self): # --------------------------------------------------------------------------- class TestAppArgparse: - """app.py __main__ block must expose the same flags as cursor's app.py.""" - - def _build_parser(self) -> argparse.ArgumentParser: - """Re-create the argparse parser from app.py without importing Flask.""" - parser = argparse.ArgumentParser(description="Claude Code Chat Browser") - parser.add_argument("--port", type=int, default=5000) - parser.add_argument("--host", default="127.0.0.1") - parser.add_argument("--debug", action="store_true", default=False) - parser.add_argument("--base-dir", default=None) - parser.add_argument("--exclude-rules", "-e", default=None, - metavar="PATH", dest="exclude_rules") - return parser + """app.py CLI must expose the same flags as cursor's app.py.""" def test_host_default_is_localhost(self): """Default host must be 127.0.0.1 to match cursor which binds to localhost only.""" - parser = self._build_parser() + parser = build_cli_parser() args = parser.parse_args([]) assert args.host == "127.0.0.1" def test_host_override(self): - parser = self._build_parser() + parser = build_cli_parser() args = parser.parse_args(["--host", "127.0.0.1"]) assert args.host == "127.0.0.1" def test_debug_default_is_false(self): - parser = self._build_parser() + parser = build_cli_parser() args = parser.parse_args([]) assert args.debug is False def test_debug_explicit_true(self): - parser = self._build_parser() + parser = build_cli_parser() args = parser.parse_args(["--debug"]) assert args.debug is True @@ -297,38 +306,38 @@ def test_app_py_debug_not_hardcoded_true(self): assert any(_debug_kwarg_uses_args(c) for c in app_run_calls) def test_port_default(self): - parser = self._build_parser() + parser = build_cli_parser() args = parser.parse_args([]) assert args.port == 5000 def test_port_override(self): - parser = self._build_parser() + parser = build_cli_parser() args = parser.parse_args(["--port", "8080"]) assert args.port == 8080 def test_base_dir_default_none(self): - parser = self._build_parser() + parser = build_cli_parser() args = parser.parse_args([]) assert args.base_dir is None def test_base_dir_override(self): - parser = self._build_parser() + parser = build_cli_parser() args = parser.parse_args(["--base-dir", "/tmp/projects"]) assert args.base_dir == "/tmp/projects" def test_exclude_rules_default_none(self): - parser = self._build_parser() + parser = build_cli_parser() args = parser.parse_args([]) assert args.exclude_rules is None def test_exclude_rules_long_form(self): - parser = self._build_parser() + parser = build_cli_parser() args = parser.parse_args(["--exclude-rules", "/tmp/rules.txt"]) assert args.exclude_rules == "/tmp/rules.txt" def test_exclude_rules_short_form(self): """Cursor's app.py uses -e as the short form; claude must too.""" - parser = self._build_parser() + parser = build_cli_parser() args = parser.parse_args(["-e", "/tmp/rules.txt"]) assert args.exclude_rules == "/tmp/rules.txt" @@ -353,11 +362,15 @@ def test_app_py_host_default_is_localhost(self): assert '"127.0.0.1"' in src def test_app_py_use_reloader_is_platform_aware(self): - """use_reloader must depend on sys.platform, not be hardcoded False.""" + """use_reloader must be opt-in via --debug and gated on non-Windows platforms.""" app_path = os.path.join(REPO_ROOT, "app.py") - with open(app_path, "r", encoding="utf-8") as f: + with open(app_path, encoding="utf-8") as f: + tree = ast.parse(f.read(), filename=app_path) + app_run_calls = [n for n in ast.walk(tree) if _is_app_run_call(n)] + assert app_run_calls + assert all(_use_reloader_kwarg_tied_to_debug(c) for c in app_run_calls) + with open(app_path, encoding="utf-8") as f: src = f.read() assert "sys.platform" in src assert "win32" in src - # Must NOT have unconditional use_reloader=False assert "use_reloader=False" not in src From bd3f9a0b2471b5d9c2fed776cd90283e6ed4c3f6 Mon Sep 17 00:00:00 2001 From: chen Date: Tue, 26 May 2026 01:57:28 +0800 Subject: [PATCH 4/5] Fix: Replaced with strict AST checks --- tests/test_cli_args.py | 52 ++++++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/tests/test_cli_args.py b/tests/test_cli_args.py index 2232c23..d1c77d4 100644 --- a/tests/test_cli_args.py +++ b/tests/test_cli_args.py @@ -62,22 +62,45 @@ def _debug_kwarg_uses_args(call: ast.Call) -> bool: return False -def _ast_mentions_args_debug(node: ast.AST) -> bool: - for child in ast.walk(node): - if ( - isinstance(child, ast.Attribute) - and child.attr == "debug" - and isinstance(child.value, ast.Name) - and child.value.id == "args" - ): - return True - return False +def _is_args_debug(node: ast.AST) -> bool: + return ( + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id == "args" + and node.attr == "debug" + ) + + +def _is_sys_platform_ne_win32(node: ast.AST) -> bool: + if not isinstance(node, ast.Compare) or len(node.ops) != 1 or len(node.comparators) != 1: + return False + if not isinstance(node.ops[0], ast.NotEq): + return False + left = node.left + if not ( + isinstance(left, ast.Attribute) + and isinstance(left.value, ast.Name) + and left.value.id == "sys" + and left.attr == "platform" + ): + return False + right = node.comparators[0] + if isinstance(right, ast.Constant): + return right.value == "win32" + return isinstance(right, ast.Str) and right.s == "win32" # py<3.8 + + +def _is_debug_and_platform_guard(node: ast.AST) -> bool: + """True for ``args.debug and (sys.platform != "win32")``.""" + if not isinstance(node, ast.BoolOp) or not isinstance(node.op, ast.And) or len(node.values) != 2: + return False + return _is_args_debug(node.values[0]) and _is_sys_platform_ne_win32(node.values[1]) def _use_reloader_kwarg_tied_to_debug(call: ast.Call) -> bool: for kw in call.keywords: if kw.arg == "use_reloader": - return _ast_mentions_args_debug(kw.value) + return _is_debug_and_platform_guard(kw.value) return False @@ -362,15 +385,10 @@ def test_app_py_host_default_is_localhost(self): assert '"127.0.0.1"' in src def test_app_py_use_reloader_is_platform_aware(self): - """use_reloader must be opt-in via --debug and gated on non-Windows platforms.""" + """use_reloader must be ``args.debug and (sys.platform != \"win32\")``.""" app_path = os.path.join(REPO_ROOT, "app.py") with open(app_path, encoding="utf-8") as f: tree = ast.parse(f.read(), filename=app_path) app_run_calls = [n for n in ast.walk(tree) if _is_app_run_call(n)] assert app_run_calls assert all(_use_reloader_kwarg_tied_to_debug(c) for c in app_run_calls) - with open(app_path, encoding="utf-8") as f: - src = f.read() - assert "sys.platform" in src - assert "win32" in src - assert "use_reloader=False" not in src From c3c318a7f85617222763a12d51395bb360c14c74 Mon Sep 17 00:00:00 2001 From: chen Date: Tue, 26 May 2026 02:05:30 +0800 Subject: [PATCH 5/5] Fix: reverse ordeer --- tests/test_cli_args.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_cli_args.py b/tests/test_cli_args.py index d1c77d4..3a0da67 100644 --- a/tests/test_cli_args.py +++ b/tests/test_cli_args.py @@ -91,10 +91,13 @@ def _is_sys_platform_ne_win32(node: ast.AST) -> bool: def _is_debug_and_platform_guard(node: ast.AST) -> bool: - """True for ``args.debug and (sys.platform != "win32")``.""" + """True for ``args.debug and (sys.platform != "win32")`` in either operand order.""" if not isinstance(node, ast.BoolOp) or not isinstance(node.op, ast.And) or len(node.values) != 2: return False - return _is_args_debug(node.values[0]) and _is_sys_platform_ne_win32(node.values[1]) + a, b = node.values + return (_is_args_debug(a) and _is_sys_platform_ne_win32(b)) or ( + _is_args_debug(b) and _is_sys_platform_ne_win32(a) + ) def _use_reloader_kwarg_tied_to_debug(call: ast.Call) -> bool: