axion-code 1.0.0__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.
Files changed (82) hide show
  1. axion/__init__.py +3 -0
  2. axion/api/__init__.py +0 -0
  3. axion/api/anthropic.py +460 -0
  4. axion/api/client.py +259 -0
  5. axion/api/error.py +161 -0
  6. axion/api/ollama.py +597 -0
  7. axion/api/openai_compat.py +805 -0
  8. axion/api/openai_responses.py +627 -0
  9. axion/api/prompt_cache.py +31 -0
  10. axion/api/sse.py +98 -0
  11. axion/api/types.py +451 -0
  12. axion/cli/__init__.py +0 -0
  13. axion/cli/init_cmd.py +50 -0
  14. axion/cli/input.py +290 -0
  15. axion/cli/main.py +2953 -0
  16. axion/cli/render.py +489 -0
  17. axion/cli/tui.py +766 -0
  18. axion/commands/__init__.py +0 -0
  19. axion/commands/handlers/__init__.py +0 -0
  20. axion/commands/handlers/agents.py +51 -0
  21. axion/commands/handlers/builtin_commands.py +367 -0
  22. axion/commands/handlers/mcp.py +59 -0
  23. axion/commands/handlers/models.py +75 -0
  24. axion/commands/handlers/plugins.py +55 -0
  25. axion/commands/handlers/skills.py +61 -0
  26. axion/commands/parsing.py +317 -0
  27. axion/commands/registry.py +166 -0
  28. axion/compat_harness/__init__.py +0 -0
  29. axion/compat_harness/extractor.py +145 -0
  30. axion/plugins/__init__.py +0 -0
  31. axion/plugins/hooks.py +22 -0
  32. axion/plugins/manager.py +391 -0
  33. axion/plugins/manifest.py +270 -0
  34. axion/runtime/__init__.py +0 -0
  35. axion/runtime/bash.py +388 -0
  36. axion/runtime/bootstrap.py +39 -0
  37. axion/runtime/claude_subscription.py +300 -0
  38. axion/runtime/compact.py +233 -0
  39. axion/runtime/config.py +397 -0
  40. axion/runtime/conversation.py +1073 -0
  41. axion/runtime/file_ops.py +613 -0
  42. axion/runtime/git.py +213 -0
  43. axion/runtime/hooks.py +235 -0
  44. axion/runtime/image.py +212 -0
  45. axion/runtime/lanes.py +282 -0
  46. axion/runtime/lsp.py +425 -0
  47. axion/runtime/mcp/__init__.py +0 -0
  48. axion/runtime/mcp/client.py +76 -0
  49. axion/runtime/mcp/lifecycle.py +96 -0
  50. axion/runtime/mcp/stdio.py +318 -0
  51. axion/runtime/mcp/tool_bridge.py +79 -0
  52. axion/runtime/memory.py +196 -0
  53. axion/runtime/oauth.py +329 -0
  54. axion/runtime/openai_subscription.py +346 -0
  55. axion/runtime/permissions.py +247 -0
  56. axion/runtime/plan_mode.py +96 -0
  57. axion/runtime/policy_engine.py +259 -0
  58. axion/runtime/prompt.py +586 -0
  59. axion/runtime/recovery.py +261 -0
  60. axion/runtime/remote.py +28 -0
  61. axion/runtime/sandbox.py +68 -0
  62. axion/runtime/scheduler.py +231 -0
  63. axion/runtime/session.py +365 -0
  64. axion/runtime/sharing.py +159 -0
  65. axion/runtime/skills.py +124 -0
  66. axion/runtime/tasks.py +258 -0
  67. axion/runtime/usage.py +241 -0
  68. axion/runtime/workers.py +186 -0
  69. axion/telemetry/__init__.py +0 -0
  70. axion/telemetry/events.py +67 -0
  71. axion/telemetry/profile.py +49 -0
  72. axion/telemetry/sink.py +60 -0
  73. axion/telemetry/tracer.py +95 -0
  74. axion/tools/__init__.py +0 -0
  75. axion/tools/lane_completion.py +33 -0
  76. axion/tools/registry.py +853 -0
  77. axion/tools/tool_search.py +226 -0
  78. axion_code-1.0.0.dist-info/METADATA +709 -0
  79. axion_code-1.0.0.dist-info/RECORD +82 -0
  80. axion_code-1.0.0.dist-info/WHEEL +4 -0
  81. axion_code-1.0.0.dist-info/entry_points.txt +2 -0
  82. axion_code-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,346 @@
1
+ """ChatGPT subscription OAuth — bypass API billing using your ChatGPT plan.
2
+
3
+ This is the same OAuth flow OpenAI's codex CLI uses. When authenticated via
4
+ subscription, requests against /v1/responses are billed against your
5
+ ChatGPT Plus / Pro / Business plan instead of pay-per-token API.
6
+
7
+ Flow (local-callback style, like the codex CLI):
8
+ 1. Open https://auth.openai.com/oauth/authorize?client_id=...&...
9
+ 2. User logs in with their ChatGPT account
10
+ 3. auth.openai.com redirects to http://localhost:1455/auth/callback?code=...
11
+ 4. We exchange the code at https://auth.openai.com/oauth/token
12
+ 5. The token response includes both `access_token` and `id_token` (JWT).
13
+ We use the access_token as a Bearer header on Responses API requests.
14
+
15
+ Tokens are saved to ~/.axion/credentials/openai-oauth.json and auto-refreshed.
16
+
17
+ NOTE: ChatGPT subscription tokens only work against the /v1/responses
18
+ endpoint with Codex models (gpt-5-codex, gpt-5-codex-mini). They do NOT
19
+ authorize regular Chat Completions or arbitrary API access. This is by
20
+ design — the subscription is scoped to the codex agent product.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import http.server
26
+ import logging
27
+ import threading
28
+ import time
29
+ import urllib.parse
30
+ from dataclasses import dataclass
31
+
32
+ from axion.runtime.oauth import (
33
+ OAuthCallbackParams,
34
+ OAuthTokenSet,
35
+ PkceCodePair,
36
+ _OAuthCallbackHandler,
37
+ clear_oauth_credentials,
38
+ generate_pkce_pair,
39
+ generate_state,
40
+ load_oauth_credentials,
41
+ open_browser,
42
+ save_oauth_credentials,
43
+ )
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+ # Codex CLI's well-known OAuth client ID (from openai/codex-cli source)
48
+ OPENAI_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
49
+
50
+ # Subscription OAuth endpoints
51
+ OPENAI_AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"
52
+ OPENAI_TOKEN_URL = "https://auth.openai.com/oauth/token"
53
+
54
+ # Local callback (codex CLI uses port 1455)
55
+ CALLBACK_PORT = 1455
56
+ CALLBACK_PATH = "/auth/callback"
57
+ REDIRECT_URI = f"http://localhost:{CALLBACK_PORT}{CALLBACK_PATH}"
58
+
59
+ # Scopes — OpenID Connect + offline access for refresh tokens
60
+ OPENAI_SCOPES = ["openid", "profile", "email", "offline_access"]
61
+
62
+ # Provider key for credential storage
63
+ SUBSCRIPTION_PROVIDER = "openai-oauth"
64
+
65
+
66
+ @dataclass
67
+ class OpenAiSubscriptionAuthResult:
68
+ """Result of an OpenAI subscription OAuth login attempt."""
69
+
70
+ success: bool
71
+ token_set: OAuthTokenSet | None = None
72
+ error: str | None = None
73
+ plan: str | None = None # "Plus" / "Pro" / "Business" / etc, parsed from id_token
74
+
75
+
76
+ def build_openai_authorize_url(pkce: PkceCodePair, state: str) -> str:
77
+ """Build the auth.openai.com authorize URL for subscription auth."""
78
+ params = {
79
+ "client_id": OPENAI_CLIENT_ID,
80
+ "response_type": "code",
81
+ "redirect_uri": REDIRECT_URI,
82
+ "scope": " ".join(OPENAI_SCOPES),
83
+ "state": state,
84
+ "code_challenge": pkce.code_challenge,
85
+ "code_challenge_method": "S256",
86
+ # Codex-specific identifier so OpenAI knows this is a CLI request
87
+ "id_token_add_organizations": "true",
88
+ "codex_cli_simplified_flow": "true",
89
+ }
90
+ return f"{OPENAI_AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
91
+
92
+
93
+ async def exchange_authorization_code(
94
+ code: str,
95
+ code_verifier: str,
96
+ ) -> OAuthTokenSet:
97
+ """Exchange an authorization code for ChatGPT subscription tokens."""
98
+ import httpx
99
+
100
+ # OAuth token endpoint accepts form-encoded body
101
+ payload = {
102
+ "grant_type": "authorization_code",
103
+ "code": code,
104
+ "code_verifier": code_verifier,
105
+ "client_id": OPENAI_CLIENT_ID,
106
+ "redirect_uri": REDIRECT_URI,
107
+ }
108
+
109
+ async with httpx.AsyncClient(timeout=30.0) as client:
110
+ response = await client.post(
111
+ OPENAI_TOKEN_URL,
112
+ data=payload,
113
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
114
+ )
115
+ if response.status_code != 200:
116
+ raise RuntimeError(
117
+ f"Token exchange failed ({response.status_code}): {response.text[:500]}"
118
+ )
119
+ data = response.json()
120
+
121
+ expires_in = data.get("expires_in")
122
+ expires_at = int(time.time()) + expires_in if expires_in else None
123
+
124
+ # Save the id_token alongside the access_token in the scopes field as a hack
125
+ # so we can extract subscription plan info later. (OAuthTokenSet doesn't
126
+ # have a dedicated id_token slot.)
127
+ scopes = data.get("scope", "").split() if data.get("scope") else OPENAI_SCOPES
128
+ id_token = data.get("id_token")
129
+ if id_token:
130
+ scopes = scopes + [f"id_token:{id_token}"]
131
+
132
+ return OAuthTokenSet(
133
+ access_token=data["access_token"],
134
+ refresh_token=data.get("refresh_token"),
135
+ expires_at=expires_at,
136
+ scopes=scopes,
137
+ )
138
+
139
+
140
+ async def refresh_openai_token(refresh_token_str: str) -> OAuthTokenSet:
141
+ """Refresh an expired ChatGPT subscription access token."""
142
+ import httpx
143
+
144
+ payload = {
145
+ "grant_type": "refresh_token",
146
+ "refresh_token": refresh_token_str,
147
+ "client_id": OPENAI_CLIENT_ID,
148
+ "scope": " ".join(OPENAI_SCOPES),
149
+ }
150
+
151
+ async with httpx.AsyncClient(timeout=30.0) as client:
152
+ response = await client.post(
153
+ OPENAI_TOKEN_URL,
154
+ data=payload,
155
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
156
+ )
157
+ if response.status_code != 200:
158
+ raise RuntimeError(
159
+ f"Token refresh failed ({response.status_code}): {response.text[:500]}"
160
+ )
161
+ data = response.json()
162
+
163
+ expires_in = data.get("expires_in")
164
+ expires_at = int(time.time()) + expires_in if expires_in else None
165
+
166
+ scopes = data.get("scope", "").split() if data.get("scope") else OPENAI_SCOPES
167
+ id_token = data.get("id_token")
168
+ if id_token:
169
+ scopes = scopes + [f"id_token:{id_token}"]
170
+
171
+ return OAuthTokenSet(
172
+ access_token=data["access_token"],
173
+ refresh_token=data.get("refresh_token", refresh_token_str),
174
+ expires_at=expires_at,
175
+ scopes=scopes,
176
+ )
177
+
178
+
179
+ async def login_with_openai_subscription(
180
+ *,
181
+ open_browser_automatically: bool = True,
182
+ timeout_seconds: float = 300.0,
183
+ ) -> OpenAiSubscriptionAuthResult:
184
+ """Run the full ChatGPT subscription OAuth login flow.
185
+
186
+ Spins up a local callback server on port 1455, opens the browser to
187
+ auth.openai.com, waits for the redirect, exchanges the code, saves
188
+ the tokens, and returns success/failure.
189
+ """
190
+ pkce = generate_pkce_pair()
191
+ state = generate_state()
192
+ auth_url = build_openai_authorize_url(pkce, state)
193
+
194
+ # Reset the shared callback handler state
195
+ _OAuthCallbackHandler.callback_result = None
196
+
197
+ # Try to start the callback server
198
+ try:
199
+ server = http.server.HTTPServer(
200
+ ("127.0.0.1", CALLBACK_PORT), _OAuthCallbackHandler
201
+ )
202
+ except OSError as exc:
203
+ return OpenAiSubscriptionAuthResult(
204
+ success=False,
205
+ error=(
206
+ f"Failed to start callback server on port {CALLBACK_PORT}: {exc}. "
207
+ f"Is another process (codex CLI?) using this port?"
208
+ ),
209
+ )
210
+
211
+ callback_result: list[OAuthCallbackParams | None] = [None]
212
+
213
+ def serve() -> None:
214
+ server.timeout = timeout_seconds
215
+ server.handle_request() # Handle exactly one request
216
+ callback_result[0] = _OAuthCallbackHandler.callback_result
217
+
218
+ thread = threading.Thread(target=serve, daemon=True)
219
+ thread.start()
220
+
221
+ # Open the browser
222
+ if open_browser_automatically:
223
+ opened = open_browser(auth_url)
224
+ if not opened:
225
+ print(f"\nCould not open browser. Visit:\n{auth_url}\n")
226
+ else:
227
+ print(f"\nVisit this URL to log in:\n{auth_url}\n")
228
+
229
+ # Wait for callback
230
+ thread.join(timeout=timeout_seconds)
231
+ server.server_close()
232
+
233
+ cb = callback_result[0]
234
+ if cb is None:
235
+ return OpenAiSubscriptionAuthResult(
236
+ success=False,
237
+ error=f"Login timed out after {int(timeout_seconds)}s. Try again.",
238
+ )
239
+
240
+ if cb.error:
241
+ return OpenAiSubscriptionAuthResult(
242
+ success=False,
243
+ error=f"OAuth error: {cb.error} - {cb.error_description or ''}",
244
+ )
245
+
246
+ if not cb.code:
247
+ return OpenAiSubscriptionAuthResult(
248
+ success=False,
249
+ error="No authorization code returned in callback.",
250
+ )
251
+
252
+ if cb.state != state:
253
+ return OpenAiSubscriptionAuthResult(
254
+ success=False,
255
+ error="State mismatch in OAuth callback (possible CSRF — try again).",
256
+ )
257
+
258
+ # Exchange code for tokens
259
+ try:
260
+ token_set = await exchange_authorization_code(
261
+ code=cb.code,
262
+ code_verifier=pkce.code_verifier,
263
+ )
264
+ except Exception as exc:
265
+ return OpenAiSubscriptionAuthResult(
266
+ success=False,
267
+ error=f"Token exchange failed: {exc}",
268
+ )
269
+
270
+ # Save tokens
271
+ save_oauth_credentials(SUBSCRIPTION_PROVIDER, token_set)
272
+ plan = _extract_plan_from_token_set(token_set)
273
+ return OpenAiSubscriptionAuthResult(success=True, token_set=token_set, plan=plan)
274
+
275
+
276
+ def _extract_plan_from_token_set(token_set: OAuthTokenSet) -> str | None:
277
+ """Extract the ChatGPT subscription plan from the saved id_token JWT."""
278
+ import base64
279
+ import json
280
+
281
+ id_token = None
282
+ for scope in token_set.scopes:
283
+ if scope.startswith("id_token:"):
284
+ id_token = scope[len("id_token:"):]
285
+ break
286
+ if not id_token:
287
+ return None
288
+
289
+ # JWT is three base64url-encoded parts separated by dots
290
+ parts = id_token.split(".")
291
+ if len(parts) < 2:
292
+ return None
293
+ payload_b64 = parts[1]
294
+ # Add padding if needed
295
+ payload_b64 += "=" * (-len(payload_b64) % 4)
296
+ try:
297
+ payload_bytes = base64.urlsafe_b64decode(payload_b64)
298
+ payload = json.loads(payload_bytes)
299
+ except (ValueError, json.JSONDecodeError):
300
+ return None
301
+
302
+ # Look for plan/subscription claims (varies by provider)
303
+ chatgpt_data = payload.get("https://api.openai.com/auth", {}) or {}
304
+ plan = chatgpt_data.get("chatgpt_plan_type")
305
+ if plan:
306
+ return str(plan).title() # "plus" -> "Plus"
307
+ return None
308
+
309
+
310
+ async def get_valid_openai_subscription_token() -> str | None:
311
+ """Return a valid ChatGPT subscription access token, refreshing if needed.
312
+
313
+ Returns None if no subscription credentials are saved.
314
+ """
315
+ creds = load_oauth_credentials(SUBSCRIPTION_PROVIDER)
316
+ if creds is None:
317
+ return None
318
+
319
+ if creds.is_expired() and creds.refresh_token:
320
+ try:
321
+ new_creds = await refresh_openai_token(creds.refresh_token)
322
+ save_oauth_credentials(SUBSCRIPTION_PROVIDER, new_creds)
323
+ return new_creds.access_token
324
+ except Exception as exc:
325
+ logger.warning("ChatGPT subscription token refresh failed: %s", exc)
326
+ return None
327
+
328
+ return creds.access_token
329
+
330
+
331
+ def has_openai_subscription_credentials() -> bool:
332
+ """Check if ChatGPT subscription credentials are saved (without validating)."""
333
+ return load_oauth_credentials(SUBSCRIPTION_PROVIDER) is not None
334
+
335
+
336
+ def get_openai_subscription_plan() -> str | None:
337
+ """Get the saved ChatGPT plan name (Plus / Pro / Business / Team)."""
338
+ creds = load_oauth_credentials(SUBSCRIPTION_PROVIDER)
339
+ if creds is None:
340
+ return None
341
+ return _extract_plan_from_token_set(creds)
342
+
343
+
344
+ def logout_openai_subscription() -> None:
345
+ """Remove ChatGPT subscription credentials."""
346
+ clear_oauth_credentials(SUBSCRIPTION_PROVIDER)
@@ -0,0 +1,247 @@
1
+ """Permission system for tool execution.
2
+
3
+ Maps to: rust/crates/runtime/src/permissions.rs
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import enum
9
+ import json
10
+ import logging
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Protocol, runtime_checkable
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class PermissionMode(enum.Enum):
19
+ READ_ONLY = "read-only"
20
+ WORKSPACE_WRITE = "workspace-write"
21
+ DANGER_FULL_ACCESS = "danger-full-access"
22
+ PROMPT = "prompt"
23
+ ALLOW = "allow"
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Permission outcomes
28
+ # ---------------------------------------------------------------------------
29
+
30
+ @dataclass(frozen=True)
31
+ class PermissionAllow:
32
+ """Tool execution is allowed."""
33
+ pass
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class PermissionDeny:
38
+ """Tool execution is denied."""
39
+ reason: str
40
+
41
+
42
+ PermissionOutcome = PermissionAllow | PermissionDeny
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Permission request / context
47
+ # ---------------------------------------------------------------------------
48
+
49
+ @dataclass
50
+ class PermissionRequest:
51
+ tool_name: str
52
+ input_json: str
53
+ current_mode: PermissionMode
54
+ required_mode: PermissionMode
55
+ reason: str = ""
56
+
57
+
58
+ @dataclass
59
+ class PermissionContext:
60
+ override_decision: PermissionOverride | None = None
61
+ override_reason: str | None = None
62
+
63
+
64
+ class PermissionOverride(enum.Enum):
65
+ ALLOW = "allow"
66
+ DENY = "deny"
67
+ ASK = "ask"
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Permission prompter protocol
72
+ # ---------------------------------------------------------------------------
73
+
74
+ class PermissionPromptDecision(enum.Enum):
75
+ ALLOW = "allow"
76
+ DENY = "deny"
77
+
78
+
79
+ @runtime_checkable
80
+ class PermissionPrompter(Protocol):
81
+ """Interactive permission decision protocol."""
82
+
83
+ async def decide(self, request: PermissionRequest) -> PermissionPromptDecision: ...
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Tool permission requirements
88
+ # ---------------------------------------------------------------------------
89
+
90
+ # Tools and their minimum required permission mode
91
+ TOOL_PERMISSION_REQUIREMENTS: dict[str, PermissionMode] = {
92
+ "Bash": PermissionMode.WORKSPACE_WRITE,
93
+ "Write": PermissionMode.WORKSPACE_WRITE,
94
+ "Edit": PermissionMode.WORKSPACE_WRITE,
95
+ "NotebookEdit": PermissionMode.WORKSPACE_WRITE,
96
+ "Read": PermissionMode.READ_ONLY,
97
+ "Glob": PermissionMode.READ_ONLY,
98
+ "Grep": PermissionMode.READ_ONLY,
99
+ "WebSearch": PermissionMode.READ_ONLY,
100
+ "WebFetch": PermissionMode.READ_ONLY,
101
+ "Agent": PermissionMode.READ_ONLY,
102
+ "TodoWrite": PermissionMode.READ_ONLY,
103
+ }
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Permission policy
108
+ # ---------------------------------------------------------------------------
109
+
110
+ class PermissionDecisionKind(enum.Enum):
111
+ """Distinguishes one-time vs persistent permission decisions."""
112
+ ALLOW_ONCE = "allow_once"
113
+ ALLOW_ALWAYS = "allow_always"
114
+ DENY = "deny"
115
+
116
+
117
+ @dataclass
118
+ class PermissionPolicy:
119
+ """Evaluates whether tool execution is allowed.
120
+
121
+ Maps to: rust/crates/runtime/src/permissions.rs::PermissionPolicy
122
+ """
123
+
124
+ mode: PermissionMode = PermissionMode.ALLOW
125
+ allow_rules: list[str] = field(default_factory=list)
126
+ deny_rules: list[str] = field(default_factory=list)
127
+ _decision_cache: dict[str, PermissionOutcome] = field(
128
+ default_factory=dict, repr=False
129
+ )
130
+
131
+ def remember_decision(
132
+ self,
133
+ tool_name: str,
134
+ outcome: PermissionOutcome,
135
+ *,
136
+ kind: PermissionDecisionKind = PermissionDecisionKind.ALLOW_ALWAYS,
137
+ ) -> None:
138
+ """Cache a permission decision for a tool.
139
+
140
+ Only ``ALLOW_ALWAYS`` decisions are cached; ``ALLOW_ONCE`` is not
141
+ stored (it applies only to the current invocation).
142
+ """
143
+ if kind == PermissionDecisionKind.ALLOW_ONCE:
144
+ return
145
+ key = f"{tool_name}:{self.mode.value}"
146
+ self._decision_cache[key] = outcome
147
+
148
+ def persist_decisions(self, path: Path) -> None:
149
+ """Save cached decisions to a JSON file."""
150
+ serializable: dict[str, dict[str, str]] = {}
151
+ for key, outcome in self._decision_cache.items():
152
+ if isinstance(outcome, PermissionAllow):
153
+ serializable[key] = {"outcome": "allow"}
154
+ elif isinstance(outcome, PermissionDeny):
155
+ serializable[key] = {"outcome": "deny", "reason": outcome.reason}
156
+
157
+ path.parent.mkdir(parents=True, exist_ok=True)
158
+ path.write_text(json.dumps(serializable, indent=2), encoding="utf-8")
159
+ logger.debug("Persisted %d permission decisions to %s", len(serializable), path)
160
+
161
+ def load_decisions(self, path: Path) -> None:
162
+ """Load cached decisions from a JSON file."""
163
+ if not path.is_file():
164
+ return
165
+ try:
166
+ data = json.loads(path.read_text(encoding="utf-8"))
167
+ except (json.JSONDecodeError, OSError) as exc:
168
+ logger.warning("Failed to load permission decisions from %s: %s", path, exc)
169
+ return
170
+
171
+ for key, value in data.items():
172
+ outcome_str = value.get("outcome", "")
173
+ if outcome_str == "allow":
174
+ self._decision_cache[key] = PermissionAllow()
175
+ elif outcome_str == "deny":
176
+ self._decision_cache[key] = PermissionDeny(
177
+ reason=value.get("reason", "persisted deny")
178
+ )
179
+
180
+ logger.debug("Loaded %d permission decisions from %s", len(self._decision_cache), path)
181
+
182
+ def authorize(
183
+ self,
184
+ tool_name: str,
185
+ input_json: str = "",
186
+ prompter: PermissionPrompter | None = None,
187
+ ) -> PermissionOutcome:
188
+ """Check if a tool invocation is allowed under current policy."""
189
+ # Check decision cache first
190
+ cache_key = f"{tool_name}:{self.mode.value}"
191
+ if cache_key in self._decision_cache:
192
+ return self._decision_cache[cache_key]
193
+
194
+ # Explicit deny rules
195
+ for rule in self.deny_rules:
196
+ if self._matches_rule(rule, tool_name):
197
+ return PermissionDeny(reason=f"Denied by rule: {rule}")
198
+
199
+ # Explicit allow rules
200
+ for rule in self.allow_rules:
201
+ if self._matches_rule(rule, tool_name):
202
+ return PermissionAllow()
203
+
204
+ # Mode-based check
205
+ if self.mode == PermissionMode.ALLOW:
206
+ return PermissionAllow()
207
+
208
+ if self.mode == PermissionMode.DANGER_FULL_ACCESS:
209
+ return PermissionAllow()
210
+
211
+ required = TOOL_PERMISSION_REQUIREMENTS.get(tool_name, PermissionMode.WORKSPACE_WRITE)
212
+
213
+ if self.mode == PermissionMode.READ_ONLY:
214
+ if required == PermissionMode.READ_ONLY:
215
+ return PermissionAllow()
216
+ return PermissionDeny(
217
+ reason=f"Tool '{tool_name}' requires {required.value}, "
218
+ f"but current mode is {self.mode.value}"
219
+ )
220
+
221
+ if self.mode == PermissionMode.WORKSPACE_WRITE:
222
+ if required in (PermissionMode.READ_ONLY, PermissionMode.WORKSPACE_WRITE):
223
+ return PermissionAllow()
224
+ return PermissionDeny(
225
+ reason=f"Tool '{tool_name}' requires {required.value}"
226
+ )
227
+
228
+ # PROMPT mode — needs interactive approval from the conversation runtime
229
+ if self.mode == PermissionMode.PROMPT:
230
+ # Return a special "needs prompt" deny that the runtime should intercept
231
+ return PermissionDeny(
232
+ reason=f"__NEEDS_PROMPT__:{tool_name}:{required.value}"
233
+ )
234
+
235
+ # Default: allow
236
+ return PermissionAllow()
237
+
238
+ @staticmethod
239
+ def _matches_rule(rule: str, tool_name: str) -> bool:
240
+ """Check if a rule pattern matches a tool name."""
241
+ if rule == "*":
242
+ return True
243
+ if rule == tool_name:
244
+ return True
245
+ if rule.endswith("*") and tool_name.startswith(rule[:-1]):
246
+ return True
247
+ return False
@@ -0,0 +1,96 @@
1
+ """Plan mode — read-only exploration and design before implementation.
2
+
3
+ When plan mode is active:
4
+ - Only read-only tools are allowed (Read, Glob, Grep, WebSearch, WebFetch)
5
+ - Write/Edit/Bash are blocked
6
+ - The system prompt is augmented with planning instructions
7
+ - The AI explores the codebase, designs an approach, and presents a plan
8
+ - User approves or rejects the plan before any code changes
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+
15
+ # Tools allowed in plan mode (read-only only)
16
+ PLAN_MODE_ALLOWED_TOOLS = {
17
+ "Read", "Glob", "Grep", "WebSearch", "WebFetch",
18
+ "ToolSearch", "Agent", # Agent can explore
19
+ }
20
+
21
+ # Tools blocked in plan mode
22
+ PLAN_MODE_BLOCKED_TOOLS = {
23
+ "Bash", "Write", "Edit", "NotebookEdit", "Skill",
24
+ }
25
+
26
+ PLAN_MODE_SYSTEM_PROMPT = """
27
+ # Plan Mode Active
28
+
29
+ You are in PLAN MODE. This means:
30
+
31
+ 1. **DO NOT write or modify any files.** Only read, search, and explore.
32
+ 2. **DO NOT run commands** that change state (no git commit, no file creation, no installs).
33
+ 3. **DO explore thoroughly.** Read relevant files, search for patterns, understand the architecture.
34
+ 4. **DO design a concrete plan.** After exploring, present a clear implementation plan.
35
+
36
+ ## Your plan should include:
37
+
38
+ ### Summary
39
+ One paragraph describing what needs to be done and why.
40
+
41
+ ### Files to Modify
42
+ List each file that needs changes, with a brief description of what changes.
43
+
44
+ ### Files to Create
45
+ List any new files needed, with their purpose.
46
+
47
+ ### Implementation Steps
48
+ Numbered steps in order of execution.
49
+
50
+ ### Risks & Considerations
51
+ Anything that could go wrong or needs careful handling.
52
+
53
+ ### Verification
54
+ How to test that the implementation works.
55
+
56
+ ## When you're done exploring and have a plan:
57
+ End your response with: **"Ready to implement. Type /plan execute to proceed."**
58
+
59
+ The user will review your plan and either approve it or ask for changes.
60
+ """
61
+
62
+
63
+ @dataclass
64
+ class PlanState:
65
+ """Tracks the current plan mode state."""
66
+
67
+ active: bool = False
68
+ task_description: str = ""
69
+ plan_text: str = ""
70
+ files_explored: list[str] = field(default_factory=list)
71
+ files_to_modify: list[str] = field(default_factory=list)
72
+ files_to_create: list[str] = field(default_factory=list)
73
+ approved: bool = False
74
+
75
+ def reset(self) -> None:
76
+ self.active = False
77
+ self.task_description = ""
78
+ self.plan_text = ""
79
+ self.files_explored.clear()
80
+ self.files_to_modify.clear()
81
+ self.files_to_create.clear()
82
+ self.approved = False
83
+
84
+
85
+ def is_tool_allowed_in_plan_mode(tool_name: str) -> bool:
86
+ """Check if a tool is allowed during plan mode."""
87
+ return tool_name in PLAN_MODE_ALLOWED_TOOLS
88
+
89
+
90
+ def get_plan_mode_denial_message(tool_name: str) -> str:
91
+ """Get the message shown when a tool is blocked in plan mode."""
92
+ return (
93
+ f"Tool '{tool_name}' is blocked in plan mode. "
94
+ f"Only read-only tools are allowed (Read, Glob, Grep, WebSearch, WebFetch). "
95
+ f"Exit plan mode with /plan execute or /plan exit to use write tools."
96
+ )