cc-plugin-codex 0.1.4__py3-none-any.whl

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.
@@ -0,0 +1,5 @@
1
+ """cc-plugin-codex: call Claude Code from Codex for bounded, read-only critique."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("cc-plugin-codex")
@@ -0,0 +1,284 @@
1
+ """Build and run the `claude` CLI invocation; classify failures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import os
8
+ import signal
9
+ import subprocess
10
+ import time
11
+ from dataclasses import dataclass
12
+ from typing import TYPE_CHECKING
13
+
14
+ import anyio
15
+ from anyio.to_thread import run_sync
16
+
17
+ from cc_plugin_codex import cli_contract, preflight
18
+ from cc_plugin_codex.config import (
19
+ INDEPENDENT_CRITIC_PROMPT,
20
+ access_flags,
21
+ config_mode_flags,
22
+ )
23
+ from cc_plugin_codex.schemas import ErrorInfo
24
+
25
+ _BUDGET_REPAIR = (
26
+ "Raise max_budget_usd or reduce context. For small prompts, try at least "
27
+ "$0.10-$0.20; lower best-effort budgets can spend and still stop before a "
28
+ "useful answer."
29
+ )
30
+
31
+ if TYPE_CHECKING:
32
+ from cc_plugin_codex.preflight import FlagSupport
33
+
34
+
35
+ @dataclass
36
+ class ClaudeRun:
37
+ stdout: str
38
+ stderr: str
39
+ exit_code: int
40
+ elapsed_ms: int
41
+ timed_out: bool
42
+
43
+
44
+ def _gate_optional(tokens: list[str], fs: FlagSupport) -> tuple[list[str], list[str]]:
45
+ """Drop any HELP_GATED flag (and its value, if it takes one) the installed
46
+ `claude` does not advertise in --help. Returns (kept_tokens, dropped_flags).
47
+ ALWAYS_SEND flags are never in HELP_GATED_FLAGS, so they always survive."""
48
+ kept: list[str] = []
49
+ dropped: list[str] = []
50
+ i = 0
51
+ while i < len(tokens):
52
+ token = tokens[i]
53
+ takes_value = cli_contract.HELP_GATED_FLAGS.get(token)
54
+ if takes_value is not None and not preflight.is_supported(token, fs):
55
+ dropped.append(token)
56
+ i += 2 if takes_value else 1
57
+ continue
58
+ kept.append(token)
59
+ i += 1
60
+ return kept, dropped
61
+
62
+
63
+ def build_command(
64
+ prompt: str,
65
+ config_mode: str,
66
+ access: str,
67
+ model: str | None,
68
+ max_budget_usd: float,
69
+ effort: str | None = None,
70
+ flag_support: FlagSupport | None = None,
71
+ ) -> tuple[list[str], list[str]]:
72
+ """Build the `claude` invocation. Returns (cmd, dropped_optional_flags).
73
+
74
+ Guarantee-bearing flags are sent unconditionally; HELP_GATED (depth/cosmetic)
75
+ flags are dropped when the installed CLI does not list them, so a minor
76
+ upstream change degrades instead of aborting a paid run. dropped_optional_flags
77
+ feeds Meta.compat_warnings."""
78
+ fs = flag_support if flag_support is not None else preflight.flag_support()
79
+ # --no-chrome disables the "Claude in Chrome" integration, which could
80
+ # otherwise open an interactive picker that hangs an unattended run until the
81
+ # timeout (burning the whole timeout and the spend) instead of answering.
82
+ tokens = [cli_contract.CLAUDE_BIN, *cli_contract.CORE_INVOCATION, "--no-chrome"]
83
+ tokens += config_mode_flags(config_mode)
84
+ tokens += access_flags(access)
85
+ tokens += ["--append-system-prompt", INDEPENDENT_CRITIC_PROMPT]
86
+ tokens += ["--max-budget-usd", f"{max_budget_usd}"]
87
+ if effort and effort in cli_contract.VALID_EFFORTS:
88
+ tokens += ["--effort", effort]
89
+ if model:
90
+ tokens += ["--model", model]
91
+ cmd, dropped = _gate_optional(tokens, fs)
92
+ # Gate BEFORE appending the prompt so a prompt that contains "--effort" etc.
93
+ # can never be mistaken for a flag.
94
+ cmd += [cli_contract.END_OF_OPTIONS, prompt]
95
+ return cmd, dropped
96
+
97
+
98
+ def auth_status(timeout_seconds: int = 10) -> tuple[bool | None, str | None]:
99
+ """Probe `claude auth status` without making a paid call.
100
+
101
+ Returns (logged_in, detail). logged_in is None when the probe could not run
102
+ (claude missing, timeout) so callers can report 'unknown' rather than a
103
+ misleading False. detail is a NON-identifying phrase, never the raw CLI output:
104
+ `claude auth status` prints the account email and organization, which would leak
105
+ into shared logs/transcripts. The boolean already carries the machine-readable
106
+ truth, so we deliberately drop the raw text."""
107
+ try:
108
+ proc = subprocess.run(
109
+ [cli_contract.CLAUDE_BIN, *cli_contract.AUTH_STATUS_ARGS],
110
+ capture_output=True,
111
+ text=True,
112
+ timeout=timeout_seconds,
113
+ check=False,
114
+ )
115
+ except (OSError, subprocess.SubprocessError):
116
+ return None, None
117
+ logged_in = proc.returncode == 0
118
+ detail = (
119
+ "Claude CLI reports an authenticated session."
120
+ if logged_in
121
+ else "Claude CLI reports no authenticated session; run `claude /login`."
122
+ )
123
+ return logged_in, detail
124
+
125
+
126
+ def _kill_process_tree(proc: subprocess.Popen) -> None:
127
+ """Best-effort terminate the process and its children. POSIX: kill the
128
+ process group (the child is its own session leader). Falls back to killing
129
+ just the process where process groups are unavailable (e.g. Windows)."""
130
+ if proc.poll() is not None:
131
+ return
132
+ try:
133
+ if hasattr(os, "killpg"):
134
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
135
+ else: # pragma: no cover - non-POSIX fallback
136
+ proc.kill()
137
+ except (ProcessLookupError, PermissionError):
138
+ with contextlib.suppress(ProcessLookupError):
139
+ proc.kill()
140
+
141
+
142
+ async def run_claude_async(cmd: list[str], cwd: str, timeout_seconds: int) -> ClaudeRun:
143
+ """Run `claude` as a subprocess, returning a ClaudeRun.
144
+
145
+ The subprocess is started in its own session (process group) so that, on a
146
+ timeout OR an MCP request cancellation, we can terminate the whole tree
147
+ rather than orphaning a paid Claude run."""
148
+ start = time.monotonic()
149
+ try:
150
+ proc = subprocess.Popen(
151
+ cmd,
152
+ cwd=cwd,
153
+ stdout=subprocess.PIPE,
154
+ stderr=subprocess.PIPE,
155
+ text=True,
156
+ start_new_session=True,
157
+ )
158
+ except OSError:
159
+ elapsed = int((time.monotonic() - start) * 1000)
160
+ return ClaudeRun("", "claude_not_found", 127, elapsed, False)
161
+
162
+ def _wait() -> tuple[str, str, bool]:
163
+ try:
164
+ out, err = proc.communicate(timeout=timeout_seconds)
165
+ return out, err, False
166
+ except subprocess.TimeoutExpired:
167
+ _kill_process_tree(proc)
168
+ out, err = proc.communicate()
169
+ return out, err, True
170
+
171
+ try:
172
+ out, err, timed_out = await run_sync(_wait, abandon_on_cancel=True)
173
+ except anyio.get_cancelled_exc_class():
174
+ _kill_process_tree(proc)
175
+ raise
176
+ elapsed = int((time.monotonic() - start) * 1000)
177
+ if timed_out:
178
+ return ClaudeRun("", "timeout", -9, elapsed, True)
179
+ return ClaudeRun(out, err, proc.returncode, elapsed, False)
180
+
181
+
182
+ def classify_failure(run: ClaudeRun) -> ErrorInfo:
183
+ env = None
184
+ with contextlib.suppress(json.JSONDecodeError, ValueError, TypeError):
185
+ env = json.loads(run.stdout)
186
+ if run.stderr == "claude_not_found":
187
+ return ErrorInfo(
188
+ code="claude_not_found",
189
+ message="The `claude` CLI was not found on PATH.",
190
+ repair="Install Claude Code and ensure `claude` is on PATH.",
191
+ )
192
+ if run.timed_out:
193
+ return ErrorInfo(
194
+ code="timeout",
195
+ message="claude exceeded the timeout.",
196
+ repair="Narrow the scope/focus or raise timeout_seconds.",
197
+ retryable=True,
198
+ )
199
+ if isinstance(env, dict) and env.get("is_error"):
200
+ subtype = str(env.get("subtype") or "").lower()
201
+ result = str(env.get("result") or "")
202
+ structured_blob = f"{subtype}\n{result}".lower()
203
+ if "api_key" in structured_blob or "invalid api key" in structured_blob:
204
+ return ErrorInfo(
205
+ code="api_key_invalid",
206
+ message="ANTHROPIC_API_KEY is invalid.",
207
+ repair="Set a valid ANTHROPIC_API_KEY, or use config_mode "
208
+ "inherit/scoped to use your existing login.",
209
+ )
210
+ if "auth" in structured_blob or "login" in structured_blob:
211
+ return ErrorInfo(
212
+ code="claude_auth_required",
213
+ message="claude is not authenticated.",
214
+ repair="Run `claude /login`.",
215
+ )
216
+ if "budget" in structured_blob:
217
+ return ErrorInfo(
218
+ code="budget_exceeded",
219
+ message="claude reached the max-budget stop threshold "
220
+ "(a best-effort limit, not a hard cap).",
221
+ repair=_BUDGET_REPAIR,
222
+ retryable=True,
223
+ )
224
+ if "permission" in structured_blob or "denied" in structured_blob:
225
+ return ErrorInfo(
226
+ code="claude_permission_error",
227
+ message="claude was denied a requested permission.",
228
+ repair="Use access=toolless, or allow the needed read-only tools.",
229
+ )
230
+ if "rate" in structured_blob or "overloaded" in structured_blob:
231
+ return ErrorInfo(
232
+ code="nonzero_exit",
233
+ message=f"claude reported a retryable error: {result[:200]}",
234
+ repair="Retry later, or reduce request size.",
235
+ retryable=True,
236
+ )
237
+
238
+ extra = ""
239
+ if isinstance(env, dict):
240
+ extra = f"{env.get('subtype', '')} {env.get('result', '')}"
241
+ blob = f"{extra}\n{run.stdout}\n{run.stderr}".lower()
242
+ if "invalid api key" in blob:
243
+ return ErrorInfo(
244
+ code="api_key_invalid",
245
+ message="ANTHROPIC_API_KEY is invalid.",
246
+ repair="Set a valid ANTHROPIC_API_KEY, or use config_mode "
247
+ "inherit/scoped to use your existing login.",
248
+ )
249
+ if "not logged in" in blob or "/login" in blob:
250
+ return ErrorInfo(
251
+ code="claude_auth_required",
252
+ message="claude is not authenticated.",
253
+ repair="Run `claude /login`.",
254
+ )
255
+ if "budget" in blob:
256
+ return ErrorInfo(
257
+ code="budget_exceeded",
258
+ message="claude reached the max-budget stop threshold "
259
+ "(a best-effort limit, not a hard cap).",
260
+ repair=_BUDGET_REPAIR,
261
+ retryable=True,
262
+ )
263
+ # An unknown flag / invalid value means the CLI contract drifted from what this
264
+ # plugin sends. Check last so an auth/budget message is never misread as drift.
265
+ if cli_contract.is_contract_drift(run.stderr, run.stdout):
266
+ return contract_changed_error()
267
+ return ErrorInfo(
268
+ code="nonzero_exit",
269
+ message=f"claude exited {run.exit_code}: {run.stderr.strip()[:200]}",
270
+ repair="Inspect the error; retry with a smaller request.",
271
+ )
272
+
273
+
274
+ def contract_changed_error() -> ErrorInfo:
275
+ """Shared cli_contract_changed error, reused across every failure path so a
276
+ drift is reported identically whether it surfaces on the sync, envelope, or
277
+ async-job path."""
278
+ return ErrorInfo(
279
+ code="cli_contract_changed",
280
+ message="claude rejected a flag or value this plugin sent — its CLI "
281
+ "contract likely changed for your installed version.",
282
+ repair="Update cc-plugin-codex (or pin claude to a supported version); "
283
+ "run claude_status to check the version.",
284
+ )
@@ -0,0 +1,122 @@
1
+ """Single source of truth for the external `claude` CLI contract.
2
+
3
+ Every assumption this server makes about the `claude` CLI — its flags,
4
+ subcommands, JSON-envelope keys, accepted effort levels, supported major
5
+ versions, and the stderr phrasings that mean the contract drifted — lives here so
6
+ an upstream breaking change is a one-file, greppable, testable edit. See
7
+ COMPATIBILITY.md for the assumption -> upstream-source map.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ CLAUDE_BIN = "claude"
13
+
14
+ # Core invocation that CANNOT be dropped: -p (print mode) + JSON output. If these
15
+ # disappear upstream the server cannot function, so a run must fail loudly rather
16
+ # than silently degrade.
17
+ CORE_INVOCATION = ("-p", "--output-format", "json")
18
+ END_OF_OPTIONS = "--"
19
+
20
+ # Subcommands / probes (free; no paid call).
21
+ VERSION_ARGS = ("--version",)
22
+ AUTH_STATUS_ARGS = ("auth", "status", "--text")
23
+ HELP_ARGS = ("--help",)
24
+
25
+ # --- Flag classes (see Item 5 of the resilience plan / COMPATIBILITY.md) --------
26
+ # ALWAYS_SEND: guarantee-bearing flags, sent unconditionally and NEVER gated on
27
+ # `--help` parsing. If upstream removes/renames one, `claude` rejects it at
28
+ # arg-parse BEFORE any model call (zero spend) and classify_failure() labels it
29
+ # cli_contract_changed. Gating these on the (inherently fuzzy) --help parse could
30
+ # silently drop a security/cost/behavioral guarantee, so we never do. All are long
31
+ # flags (the diagnostic in claude_status checks them against parsed --help).
32
+ ALWAYS_SEND_FLAGS = frozenset(
33
+ {
34
+ "--output-format", # core JSON output
35
+ "--no-chrome", # no interactive picker hanging an unattended run
36
+ "--append-system-prompt", # the independent-critic guardrails
37
+ "--max-budget-usd", # best-effort spend stop threshold
38
+ "--tools", # read-only / no-tool guarantee
39
+ "--strict-mcp-config",
40
+ "--mcp-config", # strip the user's MCP fleet (security boundary)
41
+ "--setting-sources", # scoped-mode isolation
42
+ "--bare", # bare-mode isolation
43
+ }
44
+ )
45
+
46
+ # HELP_GATED: dropping one only reduces depth or relies on a still-present primary
47
+ # guard — never a safety/cost regression. The value is whether the flag takes an
48
+ # argument (so the gate skips the value token too). These are the ONLY flags gated
49
+ # on `claude --help`; a false negative here merely drops a harmless flag.
50
+ HELP_GATED_FLAGS = {
51
+ "--effort": True, # reasoning depth only
52
+ "--model": True, # falls back to the configured default model
53
+ "--disallowed-tools": True, # defense-in-depth; --tools is the primary allowlist
54
+ "--no-session-persistence": False, # without it a session merely persists to disk
55
+ }
56
+
57
+ # Cache TTL for the `claude --help` probe, so a long-lived server re-probes after
58
+ # an in-place CLI upgrade instead of trusting a stale snapshot forever.
59
+ HELP_CACHE_TTL_SECONDS = 300
60
+
61
+ # --- Reasoning effort -----------------------------------------------------------
62
+ VALID_EFFORTS = ("low", "medium", "high", "xhigh", "max")
63
+ DEFAULT_EFFORT = "xhigh"
64
+
65
+ # --- Supported `claude` major version(s) ----------------------------------------
66
+ # A set (not a single int) so a future major can be added without a code change,
67
+ # and overridable via env so a user can opt into an untested major themselves.
68
+ SUPPORTED_MAJORS = frozenset({2})
69
+ SUPPORTED_MAJORS_ENV = "CC_PLUGIN_CODEX_SUPPORTED_MAJORS"
70
+
71
+ # --- JSON envelope keys read from `claude -p --output-format json` ---------------
72
+ # normalize.py / apply_cost_usage parse these tolerantly with .get(); listing them
73
+ # here keeps the consumed surface greppable and gives the golden-envelope test a
74
+ # canonical reference.
75
+ SUCCESS_SUBTYPES = (None, "success")
76
+ ENVELOPE_KEYS = frozenset(
77
+ {
78
+ "is_error",
79
+ "subtype",
80
+ "result",
81
+ "total_cost_usd",
82
+ "usage",
83
+ "session_id",
84
+ "modelUsage",
85
+ "permission_denials",
86
+ }
87
+ )
88
+ USAGE_KEYS = frozenset(
89
+ {
90
+ "input_tokens",
91
+ "output_tokens",
92
+ "cache_read_input_tokens",
93
+ "cache_creation_input_tokens",
94
+ }
95
+ )
96
+
97
+ # --- Contract-drift stderr signatures -------------------------------------------
98
+ # Phrasings a CLI prints when it rejects a flag or value we sent. Matching any
99
+ # (case-insensitive) reclassifies an otherwise-generic failure as
100
+ # cli_contract_changed, telling the user the plugin needs an update for their CLI
101
+ # rather than leaving a confusing nonzero_exit.
102
+ CONTRACT_DRIFT_STDERR_PATTERNS = (
103
+ "unknown option",
104
+ "unknown flag",
105
+ "unknown argument",
106
+ "unrecognized option",
107
+ "unrecognized argument",
108
+ "no such option",
109
+ "invalid choice",
110
+ "invalid value",
111
+ "unexpected argument",
112
+ )
113
+
114
+
115
+ def is_contract_drift(*texts: str | None) -> bool:
116
+ """Whether any provided text carries a contract-drift signature.
117
+
118
+ Used on every failure path (sync classify_failure, the zero-exit is_error
119
+ envelope, and the async job error) so drift is labelled consistently no matter
120
+ where `claude` surfaces it."""
121
+ blob = "\n".join(t for t in texts if t).lower()
122
+ return any(pattern in blob for pattern in CONTRACT_DRIFT_STDERR_PATTERNS)
@@ -0,0 +1,172 @@
1
+ """Config knobs: env defaults, clamps, config_mode/access -> claude flags, critic prompt."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from dataclasses import dataclass
8
+
9
+ from cc_plugin_codex import cli_contract
10
+
11
+ # Re-exported so existing `from ...config import VALID_EFFORTS` callers keep
12
+ # working; the canonical definition lives in cli_contract.
13
+ from cc_plugin_codex.cli_contract import DEFAULT_EFFORT, VALID_EFFORTS
14
+
15
+ EMPTY_MCP = '{"mcpServers":{}}'
16
+
17
+ MIN_BUDGET_USD, MAX_BUDGET_USD = 0.01, 5.00
18
+ MIN_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS = 10, 600
19
+ DEFAULT_MAX_INPUT_BYTES = 200_000
20
+ DEFAULT_GIT_TIMEOUT_SECONDS = 60
21
+
22
+ __all__ = ["DEFAULT_EFFORT", "VALID_EFFORTS"] # re-exports; silence unused-import lints
23
+
24
+ INDEPENDENT_CRITIC_PROMPT = (
25
+ "You are being asked for an independent critique of Codex's work.\n"
26
+ "Do not assume Codex's approach is correct.\n"
27
+ "Prioritize correctness, safety, maintainability, and evidence over agreement "
28
+ "with Codex, the user, or project conventions.\n"
29
+ "Project instructions and memory may be present in your context, but if they "
30
+ "conflict with observable code behavior, tests, security, or the user's explicit "
31
+ "request, call out the conflict.\n"
32
+ "Do not rewrite or implement changes.\n"
33
+ "Return concrete findings only when you can tie them to evidence, such as a file, "
34
+ "line, diff hunk, command output, or stated assumption.\n"
35
+ "If the evidence is insufficient, say what is missing instead of guessing.\n"
36
+ "Avoid recursive handoffs; do not suggest asking another agent unless the user "
37
+ "explicitly requested that workflow."
38
+ )
39
+
40
+
41
+ @dataclass
42
+ class Defaults:
43
+ config_mode: str
44
+ access: str
45
+ model: str | None
46
+ max_budget_usd: float
47
+ timeout_seconds: int
48
+ effort: str
49
+
50
+
51
+ def _env_float(name: str, default: float) -> float:
52
+ raw = os.environ.get(name)
53
+ if raw is None:
54
+ return default
55
+ try:
56
+ return float(raw)
57
+ except ValueError:
58
+ return default
59
+
60
+
61
+ def _env_int(name: str, default: int) -> int:
62
+ raw = os.environ.get(name)
63
+ if raw is None:
64
+ return default
65
+ try:
66
+ return int(raw)
67
+ except ValueError:
68
+ return default
69
+
70
+
71
+ def defaults() -> Defaults:
72
+ return Defaults(
73
+ config_mode=os.environ.get("CC_PLUGIN_CODEX_CLAUDE_CONFIG", "inherit"),
74
+ access=os.environ.get("CC_PLUGIN_CODEX_ACCESS", "toolless"),
75
+ model=os.environ.get("CC_PLUGIN_CODEX_MODEL") or None,
76
+ max_budget_usd=_env_float("CC_PLUGIN_CODEX_MAX_BUDGET_USD", 1.00),
77
+ timeout_seconds=_env_int("CC_PLUGIN_CODEX_TIMEOUT_SECONDS", 180),
78
+ effort=sanitize_effort(os.environ.get("CC_PLUGIN_CODEX_EFFORT")),
79
+ )
80
+
81
+
82
+ def sanitize_effort(value: str | None) -> str:
83
+ """Normalize an effort value to a CLI-accepted level, falling back to the
84
+ default. An invalid env value must not break a paid call, so it degrades
85
+ rather than raising."""
86
+ return value if value in VALID_EFFORTS else DEFAULT_EFFORT
87
+
88
+
89
+ def supported_majors() -> frozenset[int]:
90
+ """The `claude` CLI major versions this server is built against.
91
+
92
+ Defaults to cli_contract.SUPPORTED_MAJORS; overridable via
93
+ CC_PLUGIN_CODEX_SUPPORTED_MAJORS (comma-separated ints) so a user can opt into
94
+ an untested major. Any parse error falls back to the built-in set rather than
95
+ raising."""
96
+ raw = os.environ.get(cli_contract.SUPPORTED_MAJORS_ENV)
97
+ if not raw:
98
+ return cli_contract.SUPPORTED_MAJORS
99
+ try:
100
+ parsed = frozenset(int(part) for part in raw.split(",") if part.strip())
101
+ except ValueError:
102
+ return cli_contract.SUPPORTED_MAJORS
103
+ return parsed or cli_contract.SUPPORTED_MAJORS
104
+
105
+
106
+ def version_supported(version: str | None) -> bool | None:
107
+ """Whether the installed `claude --version` major is in supported_majors().
108
+
109
+ Returns None when the version is unknown/unparseable (so callers can report
110
+ 'unknown' rather than a false 'unsupported'). Advisory only: claude_status
111
+ surfaces a mismatch as a warning and never blocks paid calls on it."""
112
+ if not version:
113
+ return None
114
+ match = re.search(r"(\d+)\.\d+\.\d+", version)
115
+ if not match:
116
+ return None
117
+ return int(match.group(1)) in supported_majors()
118
+
119
+
120
+ def clamp_budget(value: float) -> float:
121
+ return max(MIN_BUDGET_USD, min(MAX_BUDGET_USD, value))
122
+
123
+
124
+ def clamp_timeout(value: int) -> int:
125
+ return max(MIN_TIMEOUT_SECONDS, min(MAX_TIMEOUT_SECONDS, value))
126
+
127
+
128
+ def max_input_bytes() -> int:
129
+ return max(1_000, _env_int("CC_PLUGIN_CODEX_MAX_INPUT_BYTES", DEFAULT_MAX_INPUT_BYTES))
130
+
131
+
132
+ def git_timeout_seconds() -> int:
133
+ return max(1, _env_int("CC_PLUGIN_CODEX_GIT_TIMEOUT_SECONDS", DEFAULT_GIT_TIMEOUT_SECONDS))
134
+
135
+
136
+ def bare_available() -> bool:
137
+ return bool(os.environ.get("ANTHROPIC_API_KEY"))
138
+
139
+
140
+ def config_mode_flags(mode: str) -> list[str]:
141
+ # All modes drop the user's MCP fleet (a reviewer never needs it, and it is a
142
+ # side-effect vector). inherit/scoped keep the user's login; bare needs an API key.
143
+ if mode == "inherit":
144
+ return ["--no-session-persistence", "--strict-mcp-config", "--mcp-config", EMPTY_MCP]
145
+ if mode == "scoped":
146
+ return [
147
+ "--setting-sources",
148
+ "project",
149
+ "--strict-mcp-config",
150
+ "--mcp-config",
151
+ EMPTY_MCP,
152
+ "--no-session-persistence",
153
+ ]
154
+ if mode == "bare":
155
+ return [
156
+ "--bare",
157
+ "--no-session-persistence",
158
+ "--strict-mcp-config",
159
+ "--mcp-config",
160
+ EMPTY_MCP,
161
+ ]
162
+ raise ValueError(f"unsupported config_mode: {mode}")
163
+
164
+
165
+ def access_flags(access: str) -> list[str]:
166
+ if access == "toolless":
167
+ return ["--tools", ""]
168
+ if access == "readonly":
169
+ # --tools is the PRIMARY allowlist (read-only guarantee); --disallowed-tools is
170
+ # defense-in-depth only. Never widen --tools to include write/Bash tools.
171
+ return ["--tools", "Read,Grep,Glob", "--disallowed-tools", "Edit,Write,NotebookEdit,Bash"]
172
+ raise ValueError(f"unsupported access: {access}")