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.
- axion/__init__.py +3 -0
- axion/api/__init__.py +0 -0
- axion/api/anthropic.py +460 -0
- axion/api/client.py +259 -0
- axion/api/error.py +161 -0
- axion/api/ollama.py +597 -0
- axion/api/openai_compat.py +805 -0
- axion/api/openai_responses.py +627 -0
- axion/api/prompt_cache.py +31 -0
- axion/api/sse.py +98 -0
- axion/api/types.py +451 -0
- axion/cli/__init__.py +0 -0
- axion/cli/init_cmd.py +50 -0
- axion/cli/input.py +290 -0
- axion/cli/main.py +2953 -0
- axion/cli/render.py +489 -0
- axion/cli/tui.py +766 -0
- axion/commands/__init__.py +0 -0
- axion/commands/handlers/__init__.py +0 -0
- axion/commands/handlers/agents.py +51 -0
- axion/commands/handlers/builtin_commands.py +367 -0
- axion/commands/handlers/mcp.py +59 -0
- axion/commands/handlers/models.py +75 -0
- axion/commands/handlers/plugins.py +55 -0
- axion/commands/handlers/skills.py +61 -0
- axion/commands/parsing.py +317 -0
- axion/commands/registry.py +166 -0
- axion/compat_harness/__init__.py +0 -0
- axion/compat_harness/extractor.py +145 -0
- axion/plugins/__init__.py +0 -0
- axion/plugins/hooks.py +22 -0
- axion/plugins/manager.py +391 -0
- axion/plugins/manifest.py +270 -0
- axion/runtime/__init__.py +0 -0
- axion/runtime/bash.py +388 -0
- axion/runtime/bootstrap.py +39 -0
- axion/runtime/claude_subscription.py +300 -0
- axion/runtime/compact.py +233 -0
- axion/runtime/config.py +397 -0
- axion/runtime/conversation.py +1073 -0
- axion/runtime/file_ops.py +613 -0
- axion/runtime/git.py +213 -0
- axion/runtime/hooks.py +235 -0
- axion/runtime/image.py +212 -0
- axion/runtime/lanes.py +282 -0
- axion/runtime/lsp.py +425 -0
- axion/runtime/mcp/__init__.py +0 -0
- axion/runtime/mcp/client.py +76 -0
- axion/runtime/mcp/lifecycle.py +96 -0
- axion/runtime/mcp/stdio.py +318 -0
- axion/runtime/mcp/tool_bridge.py +79 -0
- axion/runtime/memory.py +196 -0
- axion/runtime/oauth.py +329 -0
- axion/runtime/openai_subscription.py +346 -0
- axion/runtime/permissions.py +247 -0
- axion/runtime/plan_mode.py +96 -0
- axion/runtime/policy_engine.py +259 -0
- axion/runtime/prompt.py +586 -0
- axion/runtime/recovery.py +261 -0
- axion/runtime/remote.py +28 -0
- axion/runtime/sandbox.py +68 -0
- axion/runtime/scheduler.py +231 -0
- axion/runtime/session.py +365 -0
- axion/runtime/sharing.py +159 -0
- axion/runtime/skills.py +124 -0
- axion/runtime/tasks.py +258 -0
- axion/runtime/usage.py +241 -0
- axion/runtime/workers.py +186 -0
- axion/telemetry/__init__.py +0 -0
- axion/telemetry/events.py +67 -0
- axion/telemetry/profile.py +49 -0
- axion/telemetry/sink.py +60 -0
- axion/telemetry/tracer.py +95 -0
- axion/tools/__init__.py +0 -0
- axion/tools/lane_completion.py +33 -0
- axion/tools/registry.py +853 -0
- axion/tools/tool_search.py +226 -0
- axion_code-1.0.0.dist-info/METADATA +709 -0
- axion_code-1.0.0.dist-info/RECORD +82 -0
- axion_code-1.0.0.dist-info/WHEEL +4 -0
- axion_code-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|