baychat 0.29.8 → 0.30.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.
package/dist/connect.js CHANGED
@@ -36,9 +36,9 @@ exports.renderConnectMenu = renderConnectMenu;
36
36
  exports.isClaudeConnectTarget = isClaudeConnectTarget;
37
37
  exports.parseConnectClient = parseConnectClient;
38
38
  exports.writeClientConfig = writeClientConfig;
39
+ exports.describeWrittenConfigs = describeWrittenConfigs;
39
40
  exports.cmdConnect = cmdConnect;
40
41
  const node_fs_1 = __importDefault(require("node:fs"));
41
- const node_path_1 = __importDefault(require("node:path"));
42
42
  const client_config_writer_1 = require("./client-config-writer");
43
43
  const client_paths_1 = require("./client-paths");
44
44
  const commands_1 = require("./commands");
@@ -139,12 +139,33 @@ function writeClientConfig(client, endpoint, io, env = (0, client_paths_1.curren
139
139
  if (existing !== null && existing.trim() !== "") {
140
140
  io.copyFile(target, (0, client_config_writer_1.backupPathFor)(target));
141
141
  }
142
- io.mkdirp(node_path_1.default.dirname(target));
142
+ io.mkdirp((0, client_paths_1.configDirOf)(target));
143
143
  io.writeFile(target, merged.content);
144
144
  return { path: target, action: merged.action };
145
145
  });
146
146
  const [primary, ...rest] = written;
147
- return { ...primary, alsoWritten: rest.map((r) => r.path) };
147
+ return { ...primary, alsoWritten: rest.map((r) => r.path), written };
148
+ }
149
+ /**
150
+ * What `connect` prints about the files it wrote: every one, by full path.
151
+ *
152
+ * Every file is named, not just the first: a user who later edits one needs to
153
+ * know the others exist, or they change a setting and see no effect. And a
154
+ * Microsoft Store copy of Claude Desktop is SAID to be one, because its path
155
+ * (`…\Packages\Claude_…\LocalCache\…`) is not where anyone would look, and
156
+ * being able to open the file Claude actually reads is the whole verification.
157
+ */
158
+ function describeWrittenConfigs(written) {
159
+ const lines = [];
160
+ for (const [i, file] of written.entries()) {
161
+ const verb = file.action === "created" ? "Created" : file.action === "updated" ? "Updated" : "Added to";
162
+ const store = (0, client_paths_1.isClaudeStoreConfigPath)(file.path) ? " (Microsoft Store Claude)" : "";
163
+ lines.push(i === 0 ? `✓ ${verb} ${file.path}${store}` : `✓ Also ${verb.toLowerCase()} ${file.path}${store}`);
164
+ if (file.action !== "created") {
165
+ lines.push(` A copy of the previous file is at ${(0, client_config_writer_1.backupPathFor)(file.path)}`);
166
+ }
167
+ }
168
+ return lines;
148
169
  }
149
170
  /**
150
171
  * Where this client's executable actually is, when it has one.
@@ -316,19 +337,12 @@ async function runStep(step, ctx) {
316
337
  // comment because writing an empty bearer fails silently inside a client.
317
338
  throw new Error("Internal: no device credential to write — login must run first.");
318
339
  }
319
- const { path: written, action, alsoWritten } = writeClientConfig(step.client, { url: `${ctx.base}/api/mcp`, token: device.token }, realIo,
340
+ const { written } = writeClientConfig(step.client, { url: `${ctx.base}/api/mcp`, token: device.token }, realIo,
320
341
  // The resolved binary is what reveals a snap install, and a snap install
321
342
  // reads a different file than the one this client "keeps its config" in.
322
343
  { ...(0, client_paths_1.currentPathEnv)(), binaryPath: activeBinaryPath(step.client) });
323
- const verb = action === "created" ? "Created" : action === "updated" ? "Updated" : "Added to";
324
- console.log(`✓ ${verb} ${written}`);
325
- if (action !== "created") {
326
- console.log(` A copy of the previous file is at ${(0, client_config_writer_1.backupPathFor)(written)}`);
327
- }
328
- // Named rather than silent: a user who later edits one file needs to know
329
- // the other exists, or they will change a setting and see no effect.
330
- for (const extra of alsoWritten)
331
- console.log(`✓ Also written to ${extra}`);
344
+ for (const line of describeWrittenConfigs(written))
345
+ console.log(line);
332
346
  return { kind: "ok" };
333
347
  }
334
348
  case "restart-note":
@@ -35,12 +35,12 @@ const mcp_dialects_1 = require("./mcp-dialects");
35
35
  */
36
36
  function staleClientConfigs(token, io, env) {
37
37
  const stale = [];
38
- for (const path of everyKnownConfigPath(env)) {
39
- const contents = io.readFile(path);
38
+ for (const filePath of everyKnownConfigPath(env)) {
39
+ const contents = io.readFile(filePath);
40
40
  if (contents === null || !contents.includes("baychat"))
41
41
  continue;
42
42
  if (!contents.includes(token))
43
- stale.push(path);
43
+ stale.push(filePath);
44
44
  }
45
45
  return stale;
46
46
  }
@@ -55,16 +55,28 @@ function staleClientConfigs(token, io, env) {
55
55
  function refreshExistingClientConfigs(endpoint, io, env) {
56
56
  const updated = [];
57
57
  for (const client of mcp_dialects_1.MCP_CLIENTS) {
58
- for (const path of (0, client_paths_1.inspectableConfigPaths)(client, env)) {
59
- const existing = io.readFile(path);
60
- // Only files that already name BayChat. See the header: refresh is not
61
- // connect.
62
- if (existing === null || !existing.includes("baychat"))
58
+ const paths = (0, client_paths_1.inspectableConfigPaths)(client, env);
59
+ const contents = new Map(paths.map((p) => [p, io.readFile(p)]));
60
+ const names = (text) => !!text && text.includes("baychat");
61
+ // Only a client that already names BayChat somewhere. See the header:
62
+ // refresh is not connect.
63
+ if (![...contents.values()].some(names))
64
+ continue;
65
+ // ONE APP, SEVERAL FILES. Claude Desktop from the Microsoft Store reads its
66
+ // own virtualised copy, and an older `connect` wrote only the classic
67
+ // `%APPDATA%` file — so the user DID connect Claude Desktop, and the copy it
68
+ // reads has no BayChat. Once any of its files names BayChat, every location
69
+ // `connect desktop` would write is that same connected app, and is brought
70
+ // up to date with it. Codex and Cursor stay file-by-file.
71
+ const sameApp = client === "desktop" ? (0, client_paths_1.configPathsFor)(client, env) : [];
72
+ for (const filePath of paths) {
73
+ const existing = contents.get(filePath) ?? null;
74
+ if (!names(existing) && !sameApp.includes(filePath))
63
75
  continue;
64
- if (existing.includes(endpoint.token))
76
+ if (existing?.includes(endpoint.token))
65
77
  continue;
66
- if (rewrite(client, path, existing, endpoint, io))
67
- updated.push(path);
78
+ if (rewrite(client, filePath, existing, endpoint, io))
79
+ updated.push(filePath);
68
80
  }
69
81
  }
70
82
  return updated;
@@ -78,11 +90,16 @@ function refreshExistingClientConfigs(endpoint, io, env) {
78
90
  * Codex being refreshed. A partial renewal is far better than none, and the
79
91
  * caller reports what it managed.
80
92
  */
81
- function rewrite(client, path, existing, endpoint, io) {
93
+ function rewrite(client, filePath, existing, endpoint, io) {
82
94
  try {
83
95
  const merged = (0, client_config_writer_1.mergeClientConfig)((0, mcp_dialects_1.buildClientConfig)(client, endpoint), existing);
84
- io.copyFile(path, (0, client_config_writer_1.backupPathFor)(path));
85
- io.writeFile(path, merged.content);
96
+ // Backed up only when there was something to lose — the Store copy of
97
+ // Claude Desktop may not exist yet.
98
+ if (existing !== null && existing.trim() !== "")
99
+ io.copyFile(filePath, (0, client_config_writer_1.backupPathFor)(filePath));
100
+ else
101
+ io.mkdirp((0, client_paths_1.configDirOf)(filePath));
102
+ io.writeFile(filePath, merged.content);
86
103
  return true;
87
104
  }
88
105
  catch {
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.afterCall = afterCall;
4
+ exports.cmdDeniedAfter = cmdDeniedAfter;
5
+ // `baychat denied-after` — the PostToolUse / PostToolUseFailure hook that takes an "Allow once"
6
+ // rule back out as soon as the retried call has run.
7
+ //
8
+ // It is installed beside `denied-hook` (relay/launcher/approval-settings.ts) as an ASYNC hook:
9
+ // Claude Code starts it and moves on, so it adds nothing to any tool call's latency, and it prints
10
+ // nothing. It runs on every matching tool call, so it is routed by index.ts without loading the
11
+ // CLI, and it returns at once when no once-grant exists — the common case by far.
12
+ //
13
+ // The TTL sweep (grants.ts, ONCE_TTL_MS) is the backstop for a retried call that never finishes,
14
+ // and every run here sweeps too.
15
+ const hook_stdin_1 = require("../hook-stdin");
16
+ const grants_1 = require("./grants");
17
+ const permission_rule_1 = require("./permission-rule");
18
+ /** Handle one finished call. NEVER THROWS; diagnostics go to stderr. */
19
+ async function afterCall(rawStdin, log = defaultLog) {
20
+ try {
21
+ if (!(0, grants_1.hasOnceGrants)())
22
+ return;
23
+ const input = JSON.parse(rawStdin);
24
+ const call = typeof input.tool_name === "string" ? (0, permission_rule_1.callKeyOf)(input.tool_name, input.tool_input) : undefined;
25
+ if (call && typeof input.session_id === "string")
26
+ await (0, grants_1.consumeOnceGrants)(input.session_id, call);
27
+ await (0, grants_1.sweepExpiredGrants)();
28
+ }
29
+ catch (err) {
30
+ log(`baychat denied-after: ${err instanceof Error ? err.message : String(err)}`);
31
+ }
32
+ }
33
+ function defaultLog(line) {
34
+ process.stderr.write(`${line}\n`);
35
+ }
36
+ /** The command. Always 0: nothing this hook could say would change the call that already ran. */
37
+ async function cmdDeniedAfter(stdin = process.stdin) {
38
+ await afterCall(await (0, hook_stdin_1.readStdin)(stdin, 5_000));
39
+ return 0;
40
+ }
@@ -0,0 +1,234 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.ONCE_TTL_MS = void 0;
37
+ exports.grantRules = grantRules;
38
+ exports.listGrants = listGrants;
39
+ exports.coveringGrant = coveringGrant;
40
+ exports.noteRetry = noteRetry;
41
+ exports.revokeGrant = revokeGrant;
42
+ exports.consumeOnceGrants = consumeOnceGrants;
43
+ exports.sweepExpiredGrants = sweepExpiredGrants;
44
+ exports.hasOnceGrants = hasOnceGrants;
45
+ // The permission rules BayChat itself wrote on this computer, and the one owner of every change to
46
+ // them (`~/.baychat/permission-grants.json`, 0600).
47
+ //
48
+ // A GRANT is what one "Allow once" or "Always allow this exact command" answer did: the exact rules
49
+ // it added to which settings file, and the call they were for. Recording it is what lets BayChat
50
+ // take a once-rule back out after the retried call (or its TTL), list what it keeps
51
+ // (`baychat permissions list`) and remove one again (`baychat permissions remove`) — without ever
52
+ // touching a rule the person wrote themselves: a rule that was already in the file is not added,
53
+ // so it is not recorded, so it is never removed.
54
+ //
55
+ // ONE LOCK FOR ALL OF IT. The hook, the after-hook, the TTL sweeper and the command can run at the
56
+ // same time (two denials in one turn, a sweep while a retry lands), and each is a read-modify-write
57
+ // of two files. Every operation below runs under `permission-grants.lock` (an O_EXCL file, broken
58
+ // when older than STALE_LOCK_MS), so they queue instead of losing each other's writes.
59
+ const crypto = __importStar(require("crypto"));
60
+ const fs = __importStar(require("fs"));
61
+ const path = __importStar(require("path"));
62
+ const config_1 = require("../config");
63
+ const permission_rule_1 = require("./permission-rule");
64
+ const settings_rules_1 = require("./settings-rules");
65
+ /** How long a once-rule may outlive its card when the retried call never reports back. The retry
66
+ * starts within seconds; this only bounds a rule left behind by a session that went away. */
67
+ exports.ONCE_TTL_MS = 5 * 60_000;
68
+ const LOCK_WAIT_MS = 5_000;
69
+ const LOCK_POLL_MS = 50;
70
+ const STALE_LOCK_MS = 30_000;
71
+ const grantsFile = () => path.join((0, config_1.configDir)(), "permission-grants.json");
72
+ const lockFile = () => path.join((0, config_1.configDir)(), "permission-grants.lock");
73
+ /**
74
+ * Write the rules into the settings file and record the grant. All or nothing: when the record
75
+ * cannot be written the rules are taken back out, and when the rules cannot be written nothing is
76
+ * recorded.
77
+ *
78
+ * @throws with nothing changed (as far as the filesystem allows) when either write fails.
79
+ */
80
+ async function grantRules(request, now = Date.now()) {
81
+ return withGrantsLock(() => {
82
+ const added = (0, settings_rules_1.addAllowRules)(request.settingsFile, request.rules);
83
+ const grant = {
84
+ id: crypto.randomBytes(4).toString("hex"),
85
+ kind: request.kind,
86
+ rules: added,
87
+ settingsFile: request.settingsFile,
88
+ call: request.call,
89
+ shown: request.shown,
90
+ claudeSessionId: request.claudeSessionId,
91
+ ...(request.session ? { session: request.session } : {}),
92
+ addedAt: new Date(now).toISOString(),
93
+ ...(request.kind === "once" ? { expiresAt: new Date(now + exports.ONCE_TTL_MS).toISOString() } : {}),
94
+ retries: 0,
95
+ };
96
+ try {
97
+ writeGrants([...readGrants(), grant]);
98
+ }
99
+ catch (err) {
100
+ (0, settings_rules_1.removeAllowRules)(request.settingsFile, added);
101
+ throw err;
102
+ }
103
+ return grant;
104
+ });
105
+ }
106
+ /** Every recorded grant, once-grants included. */
107
+ function listGrants() {
108
+ return readGrants();
109
+ }
110
+ /**
111
+ * The live grant that already covers this call, if any: an always-grant from any session, or a
112
+ * once-grant of this Claude Code session that has not expired.
113
+ */
114
+ function coveringGrant(claudeSessionId, call, now = Date.now()) {
115
+ return readGrants().find((g) => (0, permission_rule_1.sameCall)(g.call, call) &&
116
+ (g.kind === "always" || (g.claudeSessionId === claudeSessionId && !isExpired(g, now))));
117
+ }
118
+ /** Count one silent retry against a grant. */
119
+ async function noteRetry(id) {
120
+ await withGrantsLock(() => {
121
+ writeGrants(readGrants().map((g) => (g.id === id ? { ...g, retries: g.retries + 1 } : g)));
122
+ });
123
+ }
124
+ /** Take a grant back: its rules out of the settings file, then its record. False: no such grant. */
125
+ async function revokeGrant(id) {
126
+ return withGrantsLock(() => {
127
+ const grants = readGrants();
128
+ const grant = grants.find((g) => g.id === id);
129
+ if (!grant)
130
+ return false;
131
+ revokeLocked(grant, grants);
132
+ return true;
133
+ });
134
+ }
135
+ /** After a call finished: take back the once-grants of this session that were for it. */
136
+ async function consumeOnceGrants(claudeSessionId, call) {
137
+ return revokeWhere((g) => g.kind === "once" && g.claudeSessionId === claudeSessionId && (0, permission_rule_1.sameCall)(g.call, call));
138
+ }
139
+ /** Take back every once-grant whose TTL has passed. */
140
+ async function sweepExpiredGrants(now = Date.now()) {
141
+ return revokeWhere((g) => g.kind === "once" && isExpired(g, now));
142
+ }
143
+ /** Is there any once-grant at all? The after-hook's fast exit, read without the lock. */
144
+ function hasOnceGrants() {
145
+ return readGrants().some((g) => g.kind === "once");
146
+ }
147
+ async function revokeWhere(match) {
148
+ return withGrantsLock(() => {
149
+ let grants = readGrants();
150
+ const doomed = grants.filter(match);
151
+ for (const grant of doomed)
152
+ grants = revokeLocked(grant, grants);
153
+ return doomed.length;
154
+ });
155
+ }
156
+ /** Remove `grant`'s rules — except any another remaining grant also holds — then its record.
157
+ * Returns the remaining grants. Caller holds the lock. */
158
+ function revokeLocked(grant, grants) {
159
+ const rest = grants.filter((g) => g.id !== grant.id);
160
+ const stillHeld = new Set(rest.filter((g) => g.settingsFile === grant.settingsFile).flatMap((g) => g.rules));
161
+ (0, settings_rules_1.removeAllowRules)(grant.settingsFile, grant.rules.filter((r) => !stillHeld.has(r)));
162
+ writeGrants(rest);
163
+ return rest;
164
+ }
165
+ function isExpired(grant, now) {
166
+ const at = grant.expiresAt ? Date.parse(grant.expiresAt) : Number.NaN;
167
+ // A once-grant with no readable expiry is treated as expired: it can only be taken back early.
168
+ return grant.kind === "once" && (!Number.isFinite(at) || at <= now);
169
+ }
170
+ // ─── The file ────────────────────────────────────────────────────────────────
171
+ function readGrants() {
172
+ let text;
173
+ try {
174
+ text = fs.readFileSync(grantsFile(), "utf8");
175
+ }
176
+ catch (err) {
177
+ if (err.code === "ENOENT")
178
+ return [];
179
+ throw err;
180
+ }
181
+ const parsed = JSON.parse(text);
182
+ const grants = parsed?.grants;
183
+ if (!Array.isArray(grants))
184
+ throw new Error(`${grantsFile()} is not a BayChat grants file.`);
185
+ return grants;
186
+ }
187
+ function writeGrants(grants) {
188
+ const file = grantsFile();
189
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
190
+ const tmp = `${file}.${process.pid}.tmp`;
191
+ fs.writeFileSync(tmp, `${JSON.stringify({ version: 1, grants }, null, 2)}\n`, { mode: 0o600 });
192
+ fs.renameSync(tmp, file);
193
+ }
194
+ /**
195
+ * Run `fn` holding the grants lock. The lock is an exclusive-create file; one older than
196
+ * STALE_LOCK_MS belonged to a process that died holding it and is broken. Gives up (throws) after
197
+ * LOCK_WAIT_MS rather than waiting forever — every caller fails closed on a throw.
198
+ */
199
+ async function withGrantsLock(fn) {
200
+ const file = lockFile();
201
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
202
+ const giveUpAt = Date.now() + LOCK_WAIT_MS;
203
+ for (;;) {
204
+ try {
205
+ fs.closeSync(fs.openSync(file, "wx", 0o600));
206
+ break;
207
+ }
208
+ catch (err) {
209
+ if (err.code !== "EEXIST")
210
+ throw err;
211
+ if (lockAge(file) > STALE_LOCK_MS) {
212
+ fs.rmSync(file, { force: true });
213
+ continue;
214
+ }
215
+ if (Date.now() >= giveUpAt)
216
+ throw new Error(`Another BayChat process holds ${file}; try again.`);
217
+ await new Promise((r) => setTimeout(r, LOCK_POLL_MS));
218
+ }
219
+ }
220
+ try {
221
+ return fn();
222
+ }
223
+ finally {
224
+ fs.rmSync(file, { force: true });
225
+ }
226
+ }
227
+ function lockAge(file) {
228
+ try {
229
+ return Date.now() - fs.statSync(file).mtimeMs;
230
+ }
231
+ catch {
232
+ return 0;
233
+ }
234
+ }
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.deniedCardEnabled = deniedCardEnabled;
37
+ exports.enableDeniedCard = enableDeniedCard;
38
+ exports.disableDeniedCard = disableDeniedCard;
39
+ // Whether this computer's owner turned the denied-command card on (`baychat permissions on`).
40
+ //
41
+ // OFF UNTIL SOMEBODY ASKS FOR IT, like approve-hook was: the card lets a phone add a permission
42
+ // rule on this computer, so it exists only after a deliberate act. The switch is a file in
43
+ // `~/.baychat`, read when BayChat's launcher writes a session's `--settings` file
44
+ // (relay/launcher/approval-settings.ts), so it takes effect for sessions started after it changes.
45
+ const fs = __importStar(require("fs"));
46
+ const path = __importStar(require("path"));
47
+ const config_1 = require("../config");
48
+ const optInFile = () => path.join((0, config_1.configDir)(), "denied-card.json");
49
+ /** True only when the switch file says so. Any doubt — missing, unreadable, malformed — is off. */
50
+ function deniedCardEnabled() {
51
+ try {
52
+ const parsed = JSON.parse(fs.readFileSync(optInFile(), "utf8"));
53
+ return parsed?.enabled === true;
54
+ }
55
+ catch (err) {
56
+ if (err.code !== "ENOENT") {
57
+ process.stderr.write(`baychat: ${optInFile()} could not be read, so the denied-command card is off.\n`);
58
+ }
59
+ return false;
60
+ }
61
+ }
62
+ function enableDeniedCard(now = new Date()) {
63
+ fs.mkdirSync((0, config_1.configDir)(), { recursive: true, mode: 0o700 });
64
+ fs.writeFileSync(optInFile(), `${JSON.stringify({ enabled: true, since: now.toISOString() }, null, 2)}\n`, { mode: 0o600 });
65
+ }
66
+ function disableDeniedCard() {
67
+ fs.rmSync(optInFile(), { force: true });
68
+ }
@@ -0,0 +1,183 @@
1
+ "use strict";
2
+ // What BayChat may write into Claude Code's settings for ONE call that auto mode denied — and,
3
+ // just as important, what it refuses to write. Pure: no files, no network.
4
+ //
5
+ // THE RULE WE WRITE IS THE CALL, AND NOTHING WIDER. Claude Code resolves a matching
6
+ // `permissions.allow` rule before its auto-mode classifier (permission-modes docs, "How the
7
+ // classifier evaluates actions", step 1), so a rule is a real grant: whatever it matches runs
8
+ // without review. Its syntax is a pattern language — `Bash(<text>)` matches the whole command
9
+ // text with `*` as a wildcard, and a rule must match each subcommand of a chain on its own
10
+ // (permissions docs, "Compound commands"). So:
11
+ //
12
+ // • a command containing `*` is never turned into a rule: the star would be read as a wildcard
13
+ // and the rule would allow more than the person saw;
14
+ // • a command that runs another command inside it (backticks, `$( )`, brackets, a newline) has
15
+ // no exact rule at all — nothing is offered;
16
+ // • a chain (`a && b`, `a | b`, `a; b`) can only be allowed ONCE, as one rule per subcommand,
17
+ // and only when it holds no quotes, because splitting quoted shell text by hand is how a
18
+ // parser and a person end up disagreeing about what runs;
19
+ // • "Always" is offered only for ONE plain command with no variable expansion, no redirection
20
+ // and nothing that looks like a secret (a saved rule would keep that secret in a file).
21
+ //
22
+ // The card text is built from `shown`, which masks obvious secrets; the rule is built from the
23
+ // real text, because a rule that differs from the call matches nothing.
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.MAX_RULE_COMMAND_LENGTH = void 0;
26
+ exports.planDeniedCall = planDeniedCall;
27
+ exports.callKeyOf = callKeyOf;
28
+ exports.sameCall = sameCall;
29
+ exports.redactSecrets = redactSecrets;
30
+ /** Longer than this is not a command a person can check on a phone, nor one a rule should hold. */
31
+ exports.MAX_RULE_COMMAND_LENGTH = 1000;
32
+ /** Claude Code itself saves at most five rules for one approved chain (permissions docs). */
33
+ const MAX_CHAIN_PARTS = 5;
34
+ /** `&&`, `||`, `|&`, `;`, `|`, and a lone `&` that is not part of a redirection (`2>&1`, `&>`). */
35
+ const CHAIN_OPERATOR = /&&|\|\||\|&|;|\||(?<![>&])&(?![>&])/;
36
+ const CHAIN_SPLIT = /\s*(?:&&|\|\||\|&|;|\||(?<![>&])&(?![>&]))\s*/;
37
+ const NESTED_COMMAND = /`|\$\(|[()]/;
38
+ const MCP_TOOL = /^mcp__[A-Za-z0-9_-]+__[A-Za-z0-9_.-]+$/;
39
+ const str = (v) => (typeof v === "string" ? v : "");
40
+ /** Plan the rules for one denied call. Never throws. */
41
+ function planDeniedCall(toolName, toolInput) {
42
+ const input = (toolInput && typeof toolInput === "object" ? toolInput : {});
43
+ if (toolName === "Bash")
44
+ return planBash(str(input.command).trim());
45
+ if (toolName === "WebFetch")
46
+ return planWebFetch(str(input.url).trim());
47
+ if (MCP_TOOL.test(toolName)) {
48
+ return {
49
+ shown: `${toolName} ${clipJson(input)}`,
50
+ onceRules: [toolName],
51
+ alwaysAllowed: false,
52
+ limitation: "A rule for an MCP tool allows every use of that tool, so BayChat only allows it once.",
53
+ call: { tool: toolName, subject: toolName },
54
+ };
55
+ }
56
+ return nothing(`${toolName} ${clipJson(input)}`, "BayChat can only allow shell commands, web fetches and MCP tools from your phone; " +
57
+ "approve this one at the computer (/permissions → Recently denied).");
58
+ }
59
+ function planBash(command) {
60
+ const { text: shown, redacted } = redactSecrets(command);
61
+ if (!command)
62
+ return nothing("(no command)", "The blocked call carried no command.");
63
+ if (command.length > exports.MAX_RULE_COMMAND_LENGTH) {
64
+ return nothing(clip(shown), "It is too long to allow as one exact command; approve it at the computer.");
65
+ }
66
+ if (/[\r\n]/.test(command)) {
67
+ return nothing(shown, "It spans several lines, which no exact rule can describe; approve it at the computer.");
68
+ }
69
+ if (NESTED_COMMAND.test(command)) {
70
+ return nothing(shown, "It runs a command inside another (backticks, $( ) or brackets), which no exact rule can describe; " +
71
+ "approve it at the computer.");
72
+ }
73
+ if (command.includes("*")) {
74
+ return nothing(shown, "It contains *, which Claude Code reads as a wildcard, so a rule would allow more than this command; " +
75
+ "approve it at the computer.");
76
+ }
77
+ const call = { tool: "Bash", subject: command };
78
+ if (CHAIN_OPERATOR.test(command)) {
79
+ if (/["'\\]/.test(command)) {
80
+ return nothing(shown, "It chains several commands and quotes text, so BayChat cannot split it safely; ask the session to " +
81
+ "run them one at a time.");
82
+ }
83
+ const parts = command.split(CHAIN_SPLIT);
84
+ if (parts.some((p) => p === "") || parts.length > MAX_CHAIN_PARTS) {
85
+ return nothing(shown, "BayChat cannot split this chain into exact commands; approve it at the computer.");
86
+ }
87
+ return {
88
+ shown,
89
+ onceRules: [...new Set(parts.map((p) => `Bash(${p})`))],
90
+ alwaysAllowed: false,
91
+ limitation: `It chains ${parts.length} commands, so BayChat only allows it once.`,
92
+ call,
93
+ };
94
+ }
95
+ const refusal = redacted
96
+ ? "It carries what looks like a secret, and a saved rule would keep it in a settings file, so BayChat only allows it once."
97
+ : command.includes("$")
98
+ ? "It expands a variable, so the same text could run something different later; BayChat only allows it once."
99
+ : /[<>]/.test(command)
100
+ ? "It redirects to or from a file, so BayChat only allows it once."
101
+ : "";
102
+ return { shown, onceRules: [`Bash(${command})`], alwaysAllowed: refusal === "", limitation: refusal, call };
103
+ }
104
+ function planWebFetch(url) {
105
+ let host = "";
106
+ try {
107
+ host = new URL(url).hostname;
108
+ }
109
+ catch {
110
+ /* not a URL: nothing to allow */
111
+ }
112
+ const shown = redactSecrets(url).text;
113
+ if (!host || !/^[A-Za-z0-9.-]+$/.test(host))
114
+ return nothing(shown || "(no URL)", "The blocked fetch has no usable address.");
115
+ return {
116
+ shown,
117
+ onceRules: [`WebFetch(domain:${host})`],
118
+ alwaysAllowed: false,
119
+ limitation: `A WebFetch rule covers the whole of ${host}, so BayChat only allows it once.`,
120
+ call: { tool: "WebFetch", subject: host },
121
+ };
122
+ }
123
+ /** The key a call is matched by — the same for the denied call and the retried one. */
124
+ function callKeyOf(toolName, toolInput) {
125
+ const input = (toolInput && typeof toolInput === "object" ? toolInput : {});
126
+ if (toolName === "Bash") {
127
+ const command = str(input.command).trim();
128
+ return command ? { tool: "Bash", subject: command } : undefined;
129
+ }
130
+ if (toolName === "WebFetch") {
131
+ try {
132
+ return { tool: "WebFetch", subject: new URL(str(input.url).trim()).hostname };
133
+ }
134
+ catch {
135
+ return undefined;
136
+ }
137
+ }
138
+ return MCP_TOOL.test(toolName) ? { tool: toolName, subject: toolName } : undefined;
139
+ }
140
+ function sameCall(a, b) {
141
+ return a.tool === b.tool && a.subject === b.subject;
142
+ }
143
+ function nothing(shown, limitation) {
144
+ return { shown, onceRules: [], alwaysAllowed: false, limitation };
145
+ }
146
+ // ─── Redaction ───────────────────────────────────────────────────────────────
147
+ const MASK = "[redacted]";
148
+ /** `scheme://user:password@host` → `scheme://[redacted]@host`. */
149
+ const URL_CREDENTIALS = /([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi;
150
+ /** Shapes that are a credential wherever they appear. */
151
+ const KNOWN_TOKENS = /\b(?:bay_[A-Za-z0-9_]{8,}|sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{20,}|gho_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|npm_[A-Za-z0-9]{20,}|eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,})/g;
152
+ /** `Bearer <x>`, `Basic <x>`, `token <x>` (an Authorization header's value). */
153
+ const AUTH_SCHEME = /\b(Bearer|Basic|token)\s+[A-Za-z0-9._~+/=-]{8,}/gi;
154
+ /** `--token x`, `--api-key=x`, `-password x`, `--client-secret "x"`, … */
155
+ const SECRET_FLAG = /(--?[A-Za-z0-9_-]*(?:token|secret|passw(?:or)?d|pass|api[-_]?key|apikey|auth|credential|private[-_]?key)[A-Za-z0-9_-]*)(=|\s+)("[^"]*"|'[^']*'|[^\s;&|]+)/gi;
156
+ /** `FOO=…` at the start of a word: an environment assignment, whatever its name. */
157
+ const ENV_ASSIGNMENT = /(^|[\s;&|])([A-Za-z_][A-Za-z0-9_]*)=("[^"]*"|'[^']*'|[^\s;&|]+)/g;
158
+ /**
159
+ * The text with obvious secrets masked, and whether anything was. Errs towards masking: a card is
160
+ * read on a lock screen and stored in a chat, and a masked word costs nothing but a glance.
161
+ * It is not a guarantee — an unusual secret in a positional argument passes — which is one more
162
+ * reason "Always" is never offered for a command this changes.
163
+ */
164
+ function redactSecrets(text) {
165
+ const out = text
166
+ .replace(URL_CREDENTIALS, `$1${MASK}@`)
167
+ .replace(KNOWN_TOKENS, MASK)
168
+ .replace(AUTH_SCHEME, `$1 ${MASK}`)
169
+ .replace(SECRET_FLAG, (_m, flag, sep) => `${flag}${sep}${MASK}`)
170
+ .replace(ENV_ASSIGNMENT, (_m, lead, name) => `${lead}${name}=${MASK}`);
171
+ return { text: out, redacted: out !== text };
172
+ }
173
+ function clip(text, max = 600) {
174
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
175
+ }
176
+ function clipJson(value) {
177
+ try {
178
+ return clip(redactSecrets(JSON.stringify(value)).text, 400);
179
+ }
180
+ catch {
181
+ return "(its parameters could not be displayed)";
182
+ }
183
+ }