From 04f4e5d44d70770dc7cf351a012477d059894987 Mon Sep 17 00:00:00 2001 From: Daiki Urata Date: Wed, 1 Jul 2026 17:39:53 +0900 Subject: [PATCH 1/2] fix(claude-code): make subscription mode actually generate docs claude-code subscription runs finished with an empty module tree and no markdown while the agent falsely reported success. Two causes, both stemming from the session not being in true bypassPermissions (an org managed policy can disable bypass, downgrading --dangerously-skip-permissions to acceptEdits): 1. Workspace boundary: acceptEdits only auto-approves reads/edits inside the working dir; caw chdir's into the output subdir, so source files in the parent repo are denied. Pin cwd to the repo root for claude-code (str_replace_editor writes via absolute paths, so --output is honored). Codex keeps the output-dir cwd for its native file_change. 2. MCP tools: acceptEdits does not auto-approve --mcp-config tools, so CodeWiki's own toolkit was denied. caw only emits --disallowedTools; wrap subprocess.Popen to inject --allowedTools mcp__ for each server in the --mcp-config. Belongs upstream in caw's ClaudeCodeSession. Comments cite the official Claude Code permission-mode / CLI docs. Co-Authored-By: Claude Opus 4.8 --- codewiki/src/be/caw_backend.py | 84 +++++++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/codewiki/src/be/caw_backend.py b/codewiki/src/be/caw_backend.py index e055b33f..75633779 100644 --- a/codewiki/src/be/caw_backend.py +++ b/codewiki/src/be/caw_backend.py @@ -120,6 +120,63 @@ def _patched(self) -> list[str]: # --- end stopgap -------------------------------------------------------------- +# --- claude-code allowedTools stopgap ----------------------------------------- +# Custom MCP-server tools (added via `--mcp-config`) are not auto-approved under +# `acceptEdits`, and `bypassPermissions` may be disabled by an org managed +# policy (see the cwd note below), so caw's `--dangerously-skip-permissions` +# alone is not enough: CodeWiki's own toolkit (str_replace_editor, +# read_code_components, generate_sub_module_documentation) gets denied +# ("you haven't granted it yet"), the agent writes nothing, and the run +# "succeeds" with an empty module tree. Grant the toolkit explicitly with +# `--allowedTools` using the permission rule syntax: +# https://code.claude.com/docs/en/settings#permission-rule-syntax +# `--allowedTools` flag: https://code.claude.com/docs/en/cli-reference +# caw's ClaudeCodeSession only ever emits `--disallowedTools`, so wrap +# subprocess.Popen to inject `--allowedTools mcp__` for every server in +# the --mcp-config. Belongs upstream in caw; remove once it grows a +# first-class allowed_tools knob for its toolkit servers. +_CLAUDE_ALLOWED_PATCH_APPLIED = False + + +def _patch_claude_allowed_tools() -> None: + global _CLAUDE_ALLOWED_PATCH_APPLIED + if _CLAUDE_ALLOWED_PATCH_APPLIED: + return + import json as _json + import subprocess as _sp + + _orig_popen = _sp.Popen + + def _popen(cmd, *args, **kwargs): + try: + if ( + isinstance(cmd, (list, tuple)) + and cmd + and os.path.basename(str(cmd[0])) == "claude" + and "--mcp-config" in cmd + and "--allowedTools" not in cmd + and "--allowed-tools" not in cmd + ): + cfg_path = cmd[list(cmd).index("--mcp-config") + 1] + servers = list( + _json.load(open(cfg_path)).get("mcpServers", {}).keys() + ) + if servers: + allowed = ",".join(f"mcp__{s}" for s in servers) + cmd = list(cmd) + ["--allowedTools", allowed] + logger.info("Injected --allowedTools for MCP servers: %s", servers) + except Exception as e: # never break the spawn on a patch hiccup + logger.warning("claude allowedTools patch skipped: %s", e) + return _orig_popen(cmd, *args, **kwargs) + + _sp.Popen = _popen + _CLAUDE_ALLOWED_PATCH_APPLIED = True + + +_patch_claude_allowed_tools() +# --- end stopgap -------------------------------------------------------------- + + class CawBackend(LLMBackend): """Routes LLM operations through the claude / codex CLI subscription.""" @@ -304,9 +361,34 @@ def _run_module_agent_sync( # they're cwd-independent. Safe to mutate process-wide cwd because # documentation_generator processes modules sequentially and recursive # _run_module_agent_sync calls chdir to the same absolute_docs_path. + # caw runs claude with `--dangerously-skip-permissions`, expecting + # bypassPermissions ("Everything" runs without asking). But an org + # managed policy can DISABLE bypass mode, in which case the flag is + # downgraded to `acceptEdits` — observed here: both + # `--dangerously-skip-permissions` and `--permission-mode + # bypassPermissions` report permissionMode=acceptEdits. + # permission modes: https://code.claude.com/docs/en/permission-modes + # (see #skip-all-checks-with-bypasspermissions-mode and its + # disableBypassPermissionsMode managed setting). + # Under acceptEdits, auto-approval is limited to reads/edits INSIDE the + # working directory or additionalDirectories; paths outside that scope + # still prompt (== denied in non-interactive `-p`): + # https://code.claude.com/docs/en/permission-modes#auto-approve-file-edits-with-acceptedits-mode + # caw chdir's into the output subdir (for codex's native file_change), + # so the source tree in the PARENT repo is out-of-scope and every + # source Read is denied -> empty docs. claude writes via CodeWiki's + # str_replace_editor (absolute deps path), so it does NOT need cwd + # pinned to the output dir. Pin cwd to the repo root so BOTH the source + # tree and the output dir fall inside the workspace (alt: --add-dir, + # https://code.claude.com/docs/en/cli-reference). Codex keeps the + # output-dir chdir because its file_change resolves relative paths. original_cwd = os.getcwd() + if self._caw_provider == "codex": + run_cwd = working_dir + else: + run_cwd = str(os.path.abspath(config.repo_path)) try: - os.chdir(working_dir) + os.chdir(run_cwd) try: traj = agent.completion(user_prompt) finally: From 999767cf5fd46ca3f295721a871577a12be9fc0a Mon Sep 17 00:00:00 2001 From: anhnh2002 Date: Mon, 13 Jul 2026 16:19:12 +0700 Subject: [PATCH 2/2] review fixes: scope allowedTools patch to caw's claude module - Replace the global subprocess.Popen swap with a proxy on caw.providers.claude_code's `subprocess` reference: the real Popen stays a class (isinstance/subclass safe) and no other claude invocation in the process is rewritten. - Extract the command rewrite as a pure _with_allowed_tools(cmd) helper (unit-testable) and close the mcp-config file handle. - Resolve the repo root once in __init__: os.path.abspath is cwd-relative and _run_module_agent_sync recurses while the cwd is pinned, so re-resolving a relative repo_path mid-run would break. Co-Authored-By: Claude Fable 5 --- codewiki/src/be/caw_backend.py | 95 ++++++++++++++++++++++------------ 1 file changed, 63 insertions(+), 32 deletions(-) diff --git a/codewiki/src/be/caw_backend.py b/codewiki/src/be/caw_backend.py index 75633779..916b34e7 100644 --- a/codewiki/src/be/caw_backend.py +++ b/codewiki/src/be/caw_backend.py @@ -131,45 +131,72 @@ def _patched(self) -> list[str]: # `--allowedTools` using the permission rule syntax: # https://code.claude.com/docs/en/settings#permission-rule-syntax # `--allowedTools` flag: https://code.claude.com/docs/en/cli-reference -# caw's ClaudeCodeSession only ever emits `--disallowedTools`, so wrap -# subprocess.Popen to inject `--allowedTools mcp__` for every server in -# the --mcp-config. Belongs upstream in caw; remove once it grows a -# first-class allowed_tools knob for its toolkit servers. +# caw's ClaudeCodeSession only ever emits `--disallowedTools`, so rewrite its +# `claude` command to add `--allowedTools mcp__` for every server in +# the --mcp-config. The patch swaps the `subprocess` module reference INSIDE +# caw.providers.claude_code for a thin proxy — the global subprocess.Popen +# class stays untouched (isinstance / subclass safe) and no other claude +# invocation in this process is affected. Belongs upstream in caw; remove +# once it grows a first-class allowed_tools knob for its toolkit servers. _CLAUDE_ALLOWED_PATCH_APPLIED = False +def _with_allowed_tools(cmd): + """Append ``--allowedTools mcp__,...`` to a ``claude`` command. + + Pure ``list -> list`` transform. Applies only when *cmd* is a claude + invocation carrying ``--mcp-config`` and no explicit allow-list already; + otherwise (or on any error) *cmd* is returned unchanged. + """ + import json + + try: + if not ( + isinstance(cmd, (list, tuple)) + and cmd + and os.path.basename(str(cmd[0])) == "claude" + and "--mcp-config" in cmd + and "--allowedTools" not in cmd + and "--allowed-tools" not in cmd + ): + return cmd + # caw emits a single `--mcp-config `; only the first occurrence + # is considered. + cfg_path = cmd[list(cmd).index("--mcp-config") + 1] + with open(cfg_path) as f: + servers = list(json.load(f).get("mcpServers", {}).keys()) + if not servers: + return cmd + allowed = ",".join(f"mcp__{s}" for s in servers) + logger.info("Injected --allowedTools for MCP servers: %s", servers) + return list(cmd) + ["--allowedTools", allowed] + except Exception as e: # never break the spawn on a patch hiccup + logger.warning("claude allowedTools patch skipped: %s", e) + return cmd + + def _patch_claude_allowed_tools() -> None: global _CLAUDE_ALLOWED_PATCH_APPLIED if _CLAUDE_ALLOWED_PATCH_APPLIED: return - import json as _json - import subprocess as _sp + import subprocess - _orig_popen = _sp.Popen + from caw.providers import claude_code as _caw_claude - def _popen(cmd, *args, **kwargs): - try: - if ( - isinstance(cmd, (list, tuple)) - and cmd - and os.path.basename(str(cmd[0])) == "claude" - and "--mcp-config" in cmd - and "--allowedTools" not in cmd - and "--allowed-tools" not in cmd - ): - cfg_path = cmd[list(cmd).index("--mcp-config") + 1] - servers = list( - _json.load(open(cfg_path)).get("mcpServers", {}).keys() - ) - if servers: - allowed = ",".join(f"mcp__{s}" for s in servers) - cmd = list(cmd) + ["--allowedTools", allowed] - logger.info("Injected --allowedTools for MCP servers: %s", servers) - except Exception as e: # never break the spawn on a patch hiccup - logger.warning("claude allowedTools patch skipped: %s", e) - return _orig_popen(cmd, *args, **kwargs) - - _sp.Popen = _popen + class _SubprocessProxy: + """``subprocess`` stand-in that rewrites Popen commands. + + Every other attribute (PIPE, run, ...) delegates to the real module. + """ + + @staticmethod + def Popen(cmd, *args, **kwargs): + return subprocess.Popen(_with_allowed_tools(cmd), *args, **kwargs) + + def __getattr__(self, name): + return getattr(subprocess, name) + + _caw_claude.subprocess = _SubprocessProxy() _CLAUDE_ALLOWED_PATCH_APPLIED = True @@ -185,6 +212,10 @@ def __init__(self, config: Config) -> None: self._caw_provider = _resolve_caw_provider(config.provider) # main_model is passed straight through; empty string → caw default. self._model: str | None = config.main_model or None + # Resolve once, before any agent-run chdir can move the cwd — + # os.path.abspath is cwd-relative and _run_module_agent_sync recurses + # (via generate_sub_module_documentation) while the cwd is pinned. + self._repo_root = str(os.path.abspath(config.repo_path)) # Fail loudly here rather than producing a confusing caw error mid-run. cli = _CLI_BINARY[config.provider] @@ -321,7 +352,7 @@ def _run_module_agent_sync( deps = CodeWikiDeps( absolute_docs_path=working_dir, - absolute_repo_path=str(os.path.abspath(config.repo_path)), + absolute_repo_path=self._repo_root, registry={}, components=components, path_to_current_module=list(module_path), @@ -386,7 +417,7 @@ def _run_module_agent_sync( if self._caw_provider == "codex": run_cwd = working_dir else: - run_cwd = str(os.path.abspath(config.repo_path)) + run_cwd = self._repo_root try: os.chdir(run_cwd) try: