@giovannijecha/jecode 0.1.8 → 0.2.0

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 (47) hide show
  1. package/README.md +41 -27
  2. package/dist/account-lock.js +112 -0
  3. package/dist/accounts.js +100 -0
  4. package/dist/cli-info.js +2 -2
  5. package/dist/commands.js +27 -102
  6. package/dist/controller.js +3 -0
  7. package/dist/credential-commands.js +40 -14
  8. package/dist/credential-safety.js +2 -1
  9. package/dist/external-browser.js +53 -0
  10. package/dist/oauth-http.js +114 -0
  11. package/dist/openai-account-command.js +124 -0
  12. package/dist/openai-account.js +91 -0
  13. package/dist/openai-oauth-callback.js +186 -0
  14. package/dist/openai-oauth-tokens.js +65 -0
  15. package/dist/openai-oauth.js +203 -0
  16. package/dist/permission-command.js +107 -0
  17. package/dist/permissions.js +113 -0
  18. package/dist/provider-commands.js +22 -62
  19. package/dist/provider-label.js +10 -0
  20. package/dist/providers/anthropic.js +1 -1
  21. package/dist/providers/index.js +2 -1
  22. package/dist/providers/ollama.js +1 -1
  23. package/dist/providers/openai-codex.js +114 -0
  24. package/dist/providers/openai-stream.js +13 -9
  25. package/dist/providers/openai-wire.js +4 -4
  26. package/dist/providers/openai.js +2 -2
  27. package/dist/providers/sse.js +1 -1
  28. package/dist/settings-command.js +13 -6
  29. package/dist/tools/shell.js +1 -1
  30. package/dist/transcript.js +0 -3
  31. package/dist/tui/app-workflows.js +17 -17
  32. package/dist/tui/app.js +6 -11
  33. package/dist/tui/approve.js +6 -34
  34. package/dist/tui/blocks.js +1 -3
  35. package/dist/tui/complete.js +4 -4
  36. package/dist/tui/components/menu.js +4 -3
  37. package/dist/tui/components/misc.js +0 -6
  38. package/dist/tui/components/status.js +6 -7
  39. package/dist/tui/feedback.js +11 -9
  40. package/dist/tui/help.js +29 -0
  41. package/dist/tui/modal.js +22 -11
  42. package/dist/tui/overlay.js +15 -4
  43. package/dist/tui/picker.js +1 -1
  44. package/dist/tui/session-view.js +12 -6
  45. package/dist/version.js +14 -0
  46. package/docs/assets/brand/jeco-256.png +0 -0
  47. package/package.json +5 -1
@@ -0,0 +1,186 @@
1
+ // Loopback callback used by the browser OAuth flow.
2
+ import { timingSafeEqual } from "node:crypto";
3
+ import { readFileSync } from "node:fs";
4
+ import { createServer } from "node:http";
5
+ const CALLBACK_PORTS = [1455, 1457];
6
+ export const OPENAI_CALLBACK_PATH = "/auth/callback";
7
+ export async function openAICallback(state) {
8
+ let resolveCode = () => { };
9
+ let rejectCode = () => { };
10
+ const code = new Promise((resolve, reject) => {
11
+ resolveCode = resolve;
12
+ rejectCode = reject;
13
+ });
14
+ // A bad callback can arrive before the TUI starts awaiting `complete()`.
15
+ // Mark the rejection handled here while preserving it for the real waiter.
16
+ void code.catch(() => undefined);
17
+ let response;
18
+ let finished = false;
19
+ const handler = (request, outgoing) => {
20
+ if (finished) {
21
+ outgoing.writeHead(409).end("Sign-in already completed.");
22
+ return;
23
+ }
24
+ if (request.method !== "GET" || request.url === undefined || request.url.length > 4_096) {
25
+ outgoing.writeHead(400).end("Invalid sign-in callback.");
26
+ return;
27
+ }
28
+ const incoming = new URL(request.url, "http://localhost");
29
+ if (incoming.pathname !== OPENAI_CALLBACK_PATH) {
30
+ outgoing.writeHead(404).end("Not found.");
31
+ return;
32
+ }
33
+ const receivedState = incoming.searchParams.get("state");
34
+ // A stale browser tab or another local client can reach the fixed callback
35
+ // port. Reject that one request without letting it terminate this login.
36
+ if (!sameState(state, receivedState)) {
37
+ outgoing.writeHead(400, { "cache-control": "no-store" }).end("Invalid sign-in state.");
38
+ return;
39
+ }
40
+ finished = true;
41
+ response = outgoing;
42
+ const authError = incoming.searchParams.get("error_description") ?? incoming.searchParams.get("error");
43
+ const authorizationCode = incoming.searchParams.get("code");
44
+ if (authError !== null) {
45
+ rejectCode(new Error(`ChatGPT sign-in was rejected · ${authError.slice(0, 300)}`));
46
+ }
47
+ else if (authorizationCode === null || authorizationCode === "") {
48
+ rejectCode(new Error("ChatGPT sign-in returned no authorization code"));
49
+ }
50
+ else {
51
+ resolveCode(authorizationCode);
52
+ }
53
+ };
54
+ const listening = await firstAvailableServer(handler);
55
+ return {
56
+ port: listening.port,
57
+ code,
58
+ async respond(success) {
59
+ if (response === undefined || response.writableEnded)
60
+ return;
61
+ const flushed = new Promise((resolve) => {
62
+ response?.once("finish", resolve);
63
+ response?.once("close", resolve);
64
+ });
65
+ response.writeHead(success ? 200 : 400, {
66
+ "cache-control": "no-store",
67
+ connection: "close",
68
+ "content-security-policy": "default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
69
+ "content-type": "text/html; charset=utf-8",
70
+ "referrer-policy": "no-referrer",
71
+ "x-content-type-options": "nosniff",
72
+ });
73
+ response.end(resultPage(success));
74
+ await flushed;
75
+ },
76
+ close: () => closeServer(listening.server),
77
+ };
78
+ }
79
+ async function firstAvailableServer(handler) {
80
+ for (const port of CALLBACK_PORTS) {
81
+ const server = createServer(handler);
82
+ server.maxHeadersCount = 40;
83
+ server.headersTimeout = 5_000;
84
+ server.keepAliveTimeout = 500;
85
+ server.requestTimeout = 5_000;
86
+ try {
87
+ await listen(server, port);
88
+ return { server, port };
89
+ }
90
+ catch (error) {
91
+ await closeServer(server);
92
+ if (error.code !== "EADDRINUSE")
93
+ throw error;
94
+ }
95
+ }
96
+ throw new Error("ChatGPT sign-in could not open callback ports 1455 or 1457");
97
+ }
98
+ function listen(server, port) {
99
+ return new Promise((resolve, reject) => {
100
+ const onError = (error) => reject(error);
101
+ server.once("error", onError);
102
+ server.listen(port, "127.0.0.1", () => {
103
+ server.off("error", onError);
104
+ resolve();
105
+ });
106
+ });
107
+ }
108
+ function closeServer(server) {
109
+ return new Promise((resolve) => {
110
+ if (!server.listening) {
111
+ resolve();
112
+ return;
113
+ }
114
+ let settled = false;
115
+ let fallback;
116
+ const finish = () => {
117
+ if (settled)
118
+ return;
119
+ settled = true;
120
+ if (fallback !== undefined)
121
+ clearTimeout(fallback);
122
+ resolve();
123
+ };
124
+ fallback = setTimeout(() => {
125
+ server.closeAllConnections();
126
+ finish();
127
+ }, 500);
128
+ server.close(finish);
129
+ server.closeIdleConnections();
130
+ });
131
+ }
132
+ function resultPage(success) {
133
+ const title = success ? "Signed in to Jecode" : "Jecode sign-in failed";
134
+ const status = success ? "Authentication complete" : "Authentication stopped";
135
+ const detail = success
136
+ ? "Return to your terminal. Jecode will continue automatically."
137
+ : "Return to your terminal to see what stopped the connection.";
138
+ const state = success ? "success" : "failure";
139
+ return `<!doctype html>
140
+ <html lang="en">
141
+ <head>
142
+ <meta charset="utf-8">
143
+ <meta name="viewport" content="width=device-width, initial-scale=1">
144
+ <meta name="color-scheme" content="dark">
145
+ <title>${title}</title>
146
+ <style>
147
+ :root{--night:#000;--steel:#669bd2;--steel-soft:#8db4dd;--bright:#ebeff4;--danger:#e87070}
148
+ *{box-sizing:border-box}
149
+ body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--night);color:var(--steel-soft);font-family:"Segoe UI",system-ui,sans-serif}
150
+ main{width:min(34rem,calc(100vw - 3rem));padding:3rem 1.5rem;text-align:center}
151
+ img{display:block;width:clamp(7.5rem,20vw,10rem);height:auto;margin:0 auto 1.75rem;filter:drop-shadow(0 1.25rem 2rem rgba(102,155,210,.16))}
152
+ .rail{width:min(18rem,70vw);height:1px;margin:0 auto 1.5rem;background:linear-gradient(90deg,transparent,var(--steel),transparent)}
153
+ .status{margin:0 0 .75rem;color:var(--steel);font:600 .72rem/1.2 ui-monospace,"Cascadia Mono",monospace;letter-spacing:.14em;text-transform:uppercase}
154
+ h1{margin:0;color:var(--steel);font-size:clamp(2rem,6vw,3.25rem);font-weight:720;letter-spacing:-.04em;line-height:1.05}
155
+ p:last-of-type{max-width:30rem;margin:1.25rem auto 0;color:var(--steel-soft);font-size:1.05rem;line-height:1.6}
156
+ .failure h1,.failure .status{color:var(--danger)}
157
+ @media (prefers-reduced-motion:no-preference){main{animation:arrive .45s ease-out both}@keyframes arrive{from{opacity:0;transform:translateY(.6rem)}to{opacity:1;transform:none}}}
158
+ </style>
159
+ </head>
160
+ <body>
161
+ <main class="${state}">
162
+ <img src="${mascotDataUri()}" alt="Jeco, the Jecode gecko">
163
+ <div class="rail" aria-hidden="true"></div>
164
+ <p class="status">${status}</p>
165
+ <h1>${title}</h1>
166
+ <p>${detail}</p>
167
+ </main>
168
+ <script>history.replaceState(null,"","/auth/complete")</script>
169
+ </body>
170
+ </html>`;
171
+ }
172
+ let mascot;
173
+ function mascotDataUri() {
174
+ if (mascot === undefined) {
175
+ const file = new URL("../docs/assets/brand/jeco-256.png", import.meta.url);
176
+ mascot = `data:image/png;base64,${readFileSync(file).toString("base64")}`;
177
+ }
178
+ return mascot;
179
+ }
180
+ function sameState(expected, received) {
181
+ if (received === null)
182
+ return false;
183
+ const left = Buffer.from(expected);
184
+ const right = Buffer.from(received);
185
+ return left.length === right.length && timingSafeEqual(left, right);
186
+ }
@@ -0,0 +1,65 @@
1
+ // Validate OAuth token responses and extract the ChatGPT account claims.
2
+ const CLAIMS = "https://api.openai.com/auth";
3
+ export function openAITokenReply(value, previousRefresh) {
4
+ if (!record(value))
5
+ throw new Error("OpenAI sign-in returned an invalid token response");
6
+ const accessToken = required(value["access_token"], "access token");
7
+ const refreshToken = optional(value["refresh_token"]) ?? previousRefresh;
8
+ const expiresIn = value["expires_in"];
9
+ if (refreshToken === undefined)
10
+ throw new Error("OpenAI sign-in did not return a refresh token");
11
+ if (typeof expiresIn !== "number" || !Number.isFinite(expiresIn) || expiresIn <= 0) {
12
+ throw new Error("OpenAI sign-in did not return a valid token lifetime");
13
+ }
14
+ const idToken = optional(value["id_token"]);
15
+ return {
16
+ accessToken,
17
+ refreshToken,
18
+ expiresAt: Date.now() + Math.floor(expiresIn * 1_000),
19
+ ...(idToken === undefined ? {} : { idToken }),
20
+ };
21
+ }
22
+ export function openAIAccountFromTokens(token) {
23
+ const access = jwt(token.accessToken);
24
+ const identity = token.idToken === undefined ? undefined : jwt(token.idToken);
25
+ const auth = record(access[CLAIMS]) ? access[CLAIMS] : {};
26
+ const accountId = optional(auth["chatgpt_account_id"]) ?? optional(identity?.["chatgpt_account_id"]);
27
+ if (accountId === undefined)
28
+ throw new Error("OpenAI sign-in did not identify a ChatGPT account");
29
+ const email = optional(identity?.["email"]);
30
+ const plan = optional(auth["chatgpt_plan_type"]) ?? optional(identity?.["chatgpt_plan_type"]);
31
+ return {
32
+ accessToken: token.accessToken,
33
+ refreshToken: token.refreshToken,
34
+ expiresAt: token.expiresAt,
35
+ accountId,
36
+ ...(email === undefined ? {} : { email }),
37
+ ...(plan === undefined ? {} : { plan }),
38
+ };
39
+ }
40
+ function jwt(token) {
41
+ const part = token.split(".")[1];
42
+ if (part === undefined)
43
+ throw new Error("OpenAI sign-in returned an unreadable token");
44
+ try {
45
+ const value = JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
46
+ if (!record(value))
47
+ throw new Error("invalid payload");
48
+ return value;
49
+ }
50
+ catch {
51
+ throw new Error("OpenAI sign-in returned an unreadable token");
52
+ }
53
+ }
54
+ function required(value, label) {
55
+ const found = optional(value);
56
+ if (found === undefined)
57
+ throw new Error(`OpenAI sign-in returned no ${label}`);
58
+ return found;
59
+ }
60
+ function optional(value) {
61
+ return typeof value === "string" && value !== "" ? value : undefined;
62
+ }
63
+ function record(value) {
64
+ return typeof value === "object" && value !== null && !Array.isArray(value);
65
+ }
@@ -0,0 +1,203 @@
1
+ // OpenAI's ChatGPT OAuth protocol, implemented with Node primitives only.
2
+ //
3
+ // This module owns the authority handshake. It does not know about the TUI or
4
+ // persist anything; callers decide when an explicitly completed login becomes
5
+ // an account on disk.
6
+ import { createHash, randomBytes } from "node:crypto";
7
+ import { oauthRequest } from "./oauth-http.js";
8
+ import { OPENAI_CALLBACK_PATH, openAICallback } from "./openai-oauth-callback.js";
9
+ import { openAIAccountFromTokens, openAITokenReply, } from "./openai-oauth-tokens.js";
10
+ const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
11
+ const AUTHORITY = "https://auth.openai.com";
12
+ const AUTHORIZE = `${AUTHORITY}/oauth/authorize`;
13
+ const TOKEN = `${AUTHORITY}/oauth/token`;
14
+ const REVOKE = `${AUTHORITY}/oauth/revoke`;
15
+ const DEVICE_CODE = `${AUTHORITY}/api/accounts/deviceauth/usercode`;
16
+ const DEVICE_POLL = `${AUTHORITY}/api/accounts/deviceauth/token`;
17
+ const DEVICE_VERIFY = `${AUTHORITY}/codex/device`;
18
+ const DEVICE_REDIRECT = `${AUTHORITY}/deviceauth/callback`;
19
+ const LOGIN_LIMIT_MS = 15 * 60_000;
20
+ export async function beginBrowserLogin() {
21
+ const verifier = randomBytes(64).toString("base64url");
22
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
23
+ const state = randomBytes(32).toString("base64url");
24
+ const callback = await openAICallback(state);
25
+ const redirectUri = `http://localhost:${callback.port}${OPENAI_CALLBACK_PATH}`;
26
+ const authorize = new URL(AUTHORIZE);
27
+ authorize.search = new URLSearchParams({
28
+ response_type: "code",
29
+ client_id: CLIENT_ID,
30
+ redirect_uri: redirectUri,
31
+ scope: "openid profile email offline_access",
32
+ code_challenge: challenge,
33
+ code_challenge_method: "S256",
34
+ state,
35
+ id_token_add_organizations: "true",
36
+ codex_cli_simplified_flow: "true",
37
+ originator: "jecode",
38
+ }).toString();
39
+ return {
40
+ url: authorize.href,
41
+ async complete(signal) {
42
+ try {
43
+ const code = await abortable(callback.code, signal);
44
+ const account = openAIAccountFromTokens(await exchange({
45
+ authorizationCode: code,
46
+ verifier,
47
+ redirectUri,
48
+ }, signal));
49
+ await callback.respond(true);
50
+ return account;
51
+ }
52
+ catch (error) {
53
+ await callback.respond(false);
54
+ throw error;
55
+ }
56
+ finally {
57
+ await callback.close();
58
+ }
59
+ },
60
+ close: callback.close,
61
+ };
62
+ }
63
+ export async function beginDeviceLogin(signal) {
64
+ const start = await oauthRequest(DEVICE_CODE, { contentType: "application/json", value: { client_id: CLIENT_ID } }, signal);
65
+ const value = record(start.value) ? start.value : {};
66
+ const deviceAuthId = required(value["device_auth_id"], "device authorization id");
67
+ const code = required(value["user_code"] ?? value["usercode"], "device code");
68
+ const interval = intervalSeconds(value["interval"]);
69
+ return {
70
+ url: DEVICE_VERIFY,
71
+ code,
72
+ async complete(waitSignal) {
73
+ const combined = combine(signal, waitSignal);
74
+ const pending = await pollDevice(deviceAuthId, code, interval, combined);
75
+ return openAIAccountFromTokens(await exchange(pending, combined));
76
+ },
77
+ close: async () => { },
78
+ };
79
+ }
80
+ export async function refreshOpenAITokens(account, signal) {
81
+ const response = await oauthRequest(TOKEN, {
82
+ contentType: "application/json",
83
+ value: {
84
+ client_id: CLIENT_ID,
85
+ grant_type: "refresh_token",
86
+ refresh_token: account.refreshToken,
87
+ },
88
+ }, signal);
89
+ const token = openAITokenReply(response.value, account.refreshToken);
90
+ const refreshed = openAIAccountFromTokens(token);
91
+ if (refreshed.accountId !== account.accountId) {
92
+ throw new Error("OpenAI refreshed a different ChatGPT account · sign in again");
93
+ }
94
+ return {
95
+ ...refreshed,
96
+ ...(refreshed.email === undefined && account.email !== undefined ? { email: account.email } : {}),
97
+ ...(refreshed.plan === undefined && account.plan !== undefined ? { plan: account.plan } : {}),
98
+ };
99
+ }
100
+ export async function revokeOpenAITokens(account, signal) {
101
+ await oauthRequest(REVOKE, {
102
+ contentType: "application/json",
103
+ value: {
104
+ token: account.refreshToken,
105
+ token_type_hint: "refresh_token",
106
+ client_id: CLIENT_ID,
107
+ },
108
+ }, signal);
109
+ }
110
+ async function exchange(code, signal) {
111
+ const response = await oauthRequest(TOKEN, {
112
+ contentType: "application/x-www-form-urlencoded",
113
+ value: new URLSearchParams({
114
+ grant_type: "authorization_code",
115
+ client_id: CLIENT_ID,
116
+ code: code.authorizationCode,
117
+ code_verifier: code.verifier,
118
+ redirect_uri: code.redirectUri,
119
+ }),
120
+ }, signal);
121
+ return openAITokenReply(response.value);
122
+ }
123
+ async function pollDevice(deviceAuthId, userCode, interval, signal) {
124
+ const started = Date.now();
125
+ while (Date.now() - started < LOGIN_LIMIT_MS) {
126
+ const response = await oauthRequest(DEVICE_POLL, {
127
+ contentType: "application/json",
128
+ value: { device_auth_id: deviceAuthId, user_code: userCode },
129
+ }, signal, [200, 403, 404]);
130
+ if (response.status === 200) {
131
+ const value = record(response.value) ? response.value : {};
132
+ return {
133
+ authorizationCode: required(value["authorization_code"], "authorization code"),
134
+ verifier: required(value["code_verifier"], "code verifier"),
135
+ redirectUri: DEVICE_REDIRECT,
136
+ };
137
+ }
138
+ await sleep(interval * 1_000, signal);
139
+ }
140
+ throw new Error("ChatGPT device sign-in timed out after 15 minutes");
141
+ }
142
+ function intervalSeconds(value) {
143
+ const parsed = typeof value === "string" ? Number(value.trim()) : value;
144
+ return typeof parsed === "number" && Number.isFinite(parsed)
145
+ ? Math.max(1, Math.min(30, Math.floor(parsed)))
146
+ : 5;
147
+ }
148
+ function required(value, label) {
149
+ const found = optional(value);
150
+ if (found === undefined)
151
+ throw new Error(`OpenAI sign-in returned no ${label}`);
152
+ return found;
153
+ }
154
+ function optional(value) {
155
+ return typeof value === "string" && value !== "" ? value : undefined;
156
+ }
157
+ function record(value) {
158
+ return typeof value === "object" && value !== null && !Array.isArray(value);
159
+ }
160
+ function combine(left, right) {
161
+ if (left === undefined)
162
+ return right;
163
+ if (right === undefined)
164
+ return left;
165
+ return AbortSignal.any([left, right]);
166
+ }
167
+ function abortable(promise, signal) {
168
+ if (signal === undefined)
169
+ return promise;
170
+ if (signal.aborted)
171
+ return Promise.reject(abortReason(signal));
172
+ return new Promise((resolve, reject) => {
173
+ const onAbort = () => reject(abortReason(signal));
174
+ signal.addEventListener("abort", onAbort, { once: true });
175
+ promise.then((value) => {
176
+ signal.removeEventListener("abort", onAbort);
177
+ resolve(value);
178
+ }, (error) => {
179
+ signal.removeEventListener("abort", onAbort);
180
+ reject(error);
181
+ });
182
+ });
183
+ }
184
+ function sleep(ms, signal) {
185
+ return new Promise((resolve, reject) => {
186
+ if (signal?.aborted === true) {
187
+ reject(abortReason(signal));
188
+ return;
189
+ }
190
+ const timer = setTimeout(() => {
191
+ signal?.removeEventListener("abort", onAbort);
192
+ resolve();
193
+ }, ms);
194
+ const onAbort = () => {
195
+ clearTimeout(timer);
196
+ reject(signal === undefined ? new Error("cancelled") : abortReason(signal));
197
+ };
198
+ signal?.addEventListener("abort", onAbort, { once: true });
199
+ });
200
+ }
201
+ function abortReason(signal) {
202
+ return signal.reason instanceof Error ? signal.reason : new Error("cancelled");
203
+ }
@@ -0,0 +1,107 @@
1
+ // The session-local permission control plane exposed through /permissions.
2
+ import { heading } from "./tui/picker.js";
3
+ export async function permissionsCommand(session, host) {
4
+ const choose = host.choose;
5
+ const control = host.permissions;
6
+ if (choose === undefined || control === undefined) {
7
+ host.emit({ kind: "notice", text: "permissions need the interactive screen", tone: "warn" });
8
+ return;
9
+ }
10
+ let selected = 0;
11
+ while (true) {
12
+ const tools = control.listTools();
13
+ const index = await choose(permissionsPicker(tools, session.palette, selected));
14
+ if (index === undefined)
15
+ return;
16
+ const tool = tools[index];
17
+ if (tool === undefined)
18
+ return;
19
+ selected = index;
20
+ await configureTool(tool, control, choose, session.palette);
21
+ }
22
+ }
23
+ export function permissionsPicker(tools, pal, index = 0) {
24
+ const launchOverride = tools.some((tool) => tool.locked);
25
+ return {
26
+ title: heading("permissions", launchOverride ? "session only · auto approve at launch" : "session only", pal),
27
+ description: "Changes apply now · /new resets them",
28
+ options: tools.map((tool) => ({ label: tool.name, hint: toolHint(tool) })),
29
+ index: Math.min(Math.max(0, index), Math.max(0, tools.length - 1)),
30
+ };
31
+ }
32
+ async function configureTool(tool, control, choose, pal) {
33
+ if (tool.locked) {
34
+ await choose({
35
+ title: heading(tool.name, "launch override", pal),
36
+ description: "Restart without --auto-approve to change this tool",
37
+ options: [{ label: "allow", hint: "locked for this process" }],
38
+ index: 0,
39
+ });
40
+ return;
41
+ }
42
+ const modes = tool.dangerous ? ["ask", "allow", "deny"] : ["allow", "deny"];
43
+ const grants = control.listGrants(tool.name);
44
+ const index = await choose({
45
+ title: heading(tool.name, tool.dangerous ? "dangerous tool" : "read-only tool", pal),
46
+ description: tool.dangerous
47
+ ? "Session only · ask is the safe default"
48
+ : "Session only · deny hides this tool from the model",
49
+ options: [
50
+ ...modes.map((mode) => ({ label: mode, hint: modeHint(mode, tool.dangerous) })),
51
+ ...(grants.length === 0
52
+ ? []
53
+ : [{ label: "remembered approvals", hint: String(grants.length) }]),
54
+ ],
55
+ index: Math.max(0, modes.indexOf(tool.mode)),
56
+ });
57
+ if (index === undefined)
58
+ return;
59
+ const mode = modes[index];
60
+ if (mode !== undefined) {
61
+ control.set(tool.name, mode);
62
+ return;
63
+ }
64
+ await reviewGrants(tool.name, control, choose, pal);
65
+ }
66
+ async function reviewGrants(tool, control, choose, pal) {
67
+ let selected = 0;
68
+ while (true) {
69
+ const grants = control.listGrants(tool);
70
+ if (grants.length === 0)
71
+ return;
72
+ const options = [
73
+ ...grants.map((grant) => ({ label: grant.label, hint: "revoke" })),
74
+ ...(grants.length > 1 ? [{ label: "all remembered approvals", hint: "revoke all" }] : []),
75
+ ];
76
+ const index = await choose({
77
+ title: heading("remembered", tool, pal),
78
+ description: "Allowed without asking · this session",
79
+ options,
80
+ index: Math.min(selected, options.length - 1),
81
+ });
82
+ if (index === undefined)
83
+ return;
84
+ if (index === grants.length) {
85
+ control.revokeTool(tool);
86
+ return;
87
+ }
88
+ const grant = grants[index];
89
+ if (grant === undefined)
90
+ return;
91
+ control.revoke(grant.key);
92
+ selected = index;
93
+ }
94
+ }
95
+ function toolHint(tool) {
96
+ const kind = tool.dangerous ? "" : " · read only";
97
+ const remembered = tool.remembered === 0 ? "" : ` · ${tool.remembered} remembered`;
98
+ const locked = tool.locked ? " · launch override" : "";
99
+ return `${tool.mode}${kind}${remembered}${locked}`;
100
+ }
101
+ function modeHint(mode, dangerous) {
102
+ if (mode === "deny")
103
+ return "hide from the model";
104
+ if (mode === "ask")
105
+ return "prompt when needed";
106
+ return dangerous ? "every call this session" : "offer to the model";
107
+ }
@@ -0,0 +1,113 @@
1
+ // Session-local tool policies and remembered approval scopes.
2
+ /** One permission control plane for one interactive process. */
3
+ export function sessionPermissions(tools, autoApprove) {
4
+ const catalogue = [...tools];
5
+ const byName = new Map(catalogue.map((tool) => [tool.name, tool]));
6
+ const modes = new Map();
7
+ const grants = new Map();
8
+ const configured = (tool) => modes.get(tool.name) ?? defaultMode(tool);
9
+ const effective = (tool) => autoApprove && tool.dangerous ? "allow" : configured(tool);
10
+ const revokeTool = (name) => {
11
+ for (const [key, grant] of grants) {
12
+ if (grant.tools.includes(name))
13
+ grants.delete(key);
14
+ }
15
+ };
16
+ return {
17
+ listTools() {
18
+ return catalogue.map((tool) => ({
19
+ name: tool.name,
20
+ dangerous: tool.dangerous,
21
+ mode: effective(tool),
22
+ remembered: [...grants.values()].filter((grant) => grant.tools.includes(tool.name)).length,
23
+ locked: autoApprove && tool.dangerous,
24
+ }));
25
+ },
26
+ set(name, mode) {
27
+ const tool = byName.get(name);
28
+ if (tool === undefined || (autoApprove && tool.dangerous))
29
+ return false;
30
+ if (!tool.dangerous && mode === "ask")
31
+ return false;
32
+ if (configured(tool) === mode)
33
+ return true;
34
+ if (mode === defaultMode(tool))
35
+ modes.delete(name);
36
+ else
37
+ modes.set(name, mode);
38
+ revokeTool(name);
39
+ return true;
40
+ },
41
+ listGrants(name) {
42
+ return [...grants.values()].filter((grant) => name === undefined || grant.tools.includes(name));
43
+ },
44
+ revoke(key) {
45
+ grants.delete(key);
46
+ },
47
+ revokeTool,
48
+ reset() {
49
+ modes.clear();
50
+ grants.clear();
51
+ },
52
+ availableTools() {
53
+ return catalogue.filter((tool) => effective(tool) !== "deny");
54
+ },
55
+ approved(call) {
56
+ const tool = byName.get(call.name);
57
+ if (tool === undefined)
58
+ return false;
59
+ const mode = effective(tool);
60
+ if (mode === "allow")
61
+ return true;
62
+ if (mode === "deny")
63
+ return false;
64
+ return grants.has(scopeFor(call).key);
65
+ },
66
+ remember(call) {
67
+ const tool = byName.get(call.name);
68
+ if (tool === undefined || effective(tool) !== "ask")
69
+ return;
70
+ const scope = scopeFor(call);
71
+ grants.set(scope.key, { key: scope.key, tools: grantTools(call), label: scope.summary });
72
+ },
73
+ };
74
+ }
75
+ /** The narrow permission represented by "for the session" in an approval. */
76
+ export function scopeFor(call) {
77
+ const path = typeof call.input.path === "string" ? call.input.path : undefined;
78
+ if ((call.name === "write_file" || call.name === "edit_file") && path !== undefined) {
79
+ return { key: `file\0${path}`, label: `changes to ${path}`, summary: `file changes · ${path}` };
80
+ }
81
+ const command = typeof call.input.command === "string" ? call.input.command : undefined;
82
+ if (call.name === "run_command" && command !== undefined) {
83
+ return { key: `command\0${command}`, label: "this exact command", summary: `command · ${command}` };
84
+ }
85
+ return {
86
+ key: `${call.name}\0${stable(call.input)}`,
87
+ label: "this exact call",
88
+ summary: `${call.name} · ${target(call.input)}`,
89
+ };
90
+ }
91
+ function defaultMode(tool) {
92
+ return tool.dangerous ? "ask" : "allow";
93
+ }
94
+ function grantTools(call) {
95
+ return call.name === "write_file" || call.name === "edit_file"
96
+ ? ["edit_file", "write_file"]
97
+ : [call.name];
98
+ }
99
+ function stable(value) {
100
+ if (Array.isArray(value))
101
+ return `[${value.map(stable).join(",")}]`;
102
+ if (value !== null && typeof value === "object") {
103
+ return `{${Object.entries(value)
104
+ .sort(([a], [b]) => a.localeCompare(b))
105
+ .map(([key, item]) => `${JSON.stringify(key)}:${stable(item)}`)
106
+ .join(",")}}`;
107
+ }
108
+ return JSON.stringify(value);
109
+ }
110
+ function target(input) {
111
+ const value = input.path ?? input.command;
112
+ return typeof value === "string" ? value : stable(input);
113
+ }