baychat 0.29.9 → 0.31.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 (45) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +43 -10
  3. package/dist/approval-wait.js +143 -0
  4. package/dist/approve-hook.js +68 -133
  5. package/dist/auto-update/describe.js +66 -0
  6. package/dist/auto-update/notice.js +40 -0
  7. package/dist/auto-update/policy.js +110 -0
  8. package/dist/auto-update/relay-glue.js +143 -0
  9. package/dist/auto-update/store.js +140 -0
  10. package/dist/auto-update/updater.js +324 -0
  11. package/dist/channel/permission.js +6 -0
  12. package/dist/cli-help.js +30 -3
  13. package/dist/denied/after-hook.js +46 -0
  14. package/dist/denied/confirm.js +131 -0
  15. package/dist/denied/grants.js +292 -0
  16. package/dist/denied/hook-log.js +75 -0
  17. package/dist/denied/live-session.js +154 -0
  18. package/dist/denied/notice.js +29 -0
  19. package/dist/denied/opt-in.js +68 -0
  20. package/dist/denied/permission-rule.js +186 -0
  21. package/dist/denied/settings-rules.js +221 -0
  22. package/dist/denied/transcript.js +173 -0
  23. package/dist/denied-hook.js +431 -0
  24. package/dist/doctor-command.js +7 -1
  25. package/dist/doctor.js +28 -0
  26. package/dist/help-topics.js +12 -4
  27. package/dist/hook-stdin.js +35 -0
  28. package/dist/index.js +13 -1
  29. package/dist/main.js +20 -2
  30. package/dist/permissions-command.js +104 -0
  31. package/dist/protocol-content.js +1 -1
  32. package/dist/refresh-clients.js +36 -0
  33. package/dist/relay/acp/runner.js +4 -0
  34. package/dist/relay/commands.js +19 -3
  35. package/dist/relay/daemon.js +37 -1
  36. package/dist/relay/launcher/approval-settings.js +56 -2
  37. package/dist/relay/launcher/loop.js +4 -1
  38. package/dist/relay/self-restart.js +149 -0
  39. package/dist/relay/terminal-pane.js +74 -2
  40. package/dist/runtimes.js +2 -2
  41. package/dist/self-upgrade.js +92 -27
  42. package/dist/start-command.js +15 -1
  43. package/dist/update-command.js +51 -31
  44. package/dist/waiting-cards.js +107 -0
  45. package/package.json +4 -1
@@ -0,0 +1,186 @@
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: "\"Always\" isn't offered: a rule for an MCP tool would allow every use of it, 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
+ // One plain line on WHY, because the usual cause is the agent's own habit (`cd … && x | tail`)
92
+ // and the fix is for the session to run the command by itself.
93
+ limitation: `"Always" isn't offered: this chains ${parts.length} commands, and a saved rule only ever covers one plain ` +
94
+ `command. Run it on its own (no cd … &&, no | tail) to be offered "Always".`,
95
+ call,
96
+ };
97
+ }
98
+ const refusal = redacted
99
+ ? "\"Always\" isn't offered: it carries what looks like a secret, and a saved rule would keep it in a settings file."
100
+ : command.includes("$")
101
+ ? "\"Always\" isn't offered: it expands a variable, so the same text could run something different later."
102
+ : /[<>]/.test(command)
103
+ ? "\"Always\" isn't offered: it redirects to or from a file."
104
+ : "";
105
+ return { shown, onceRules: [`Bash(${command})`], alwaysAllowed: refusal === "", limitation: refusal, call };
106
+ }
107
+ function planWebFetch(url) {
108
+ let host = "";
109
+ try {
110
+ host = new URL(url).hostname;
111
+ }
112
+ catch {
113
+ /* not a URL: nothing to allow */
114
+ }
115
+ const shown = redactSecrets(url).text;
116
+ if (!host || !/^[A-Za-z0-9.-]+$/.test(host))
117
+ return nothing(shown || "(no URL)", "The blocked fetch has no usable address.");
118
+ return {
119
+ shown,
120
+ onceRules: [`WebFetch(domain:${host})`],
121
+ alwaysAllowed: false,
122
+ limitation: `"Always" isn't offered: a WebFetch rule covers the whole of ${host}, so BayChat only allows it once.`,
123
+ call: { tool: "WebFetch", subject: host },
124
+ };
125
+ }
126
+ /** The key a call is matched by — the same for the denied call and the retried one. */
127
+ function callKeyOf(toolName, toolInput) {
128
+ const input = (toolInput && typeof toolInput === "object" ? toolInput : {});
129
+ if (toolName === "Bash") {
130
+ const command = str(input.command).trim();
131
+ return command ? { tool: "Bash", subject: command } : undefined;
132
+ }
133
+ if (toolName === "WebFetch") {
134
+ try {
135
+ return { tool: "WebFetch", subject: new URL(str(input.url).trim()).hostname };
136
+ }
137
+ catch {
138
+ return undefined;
139
+ }
140
+ }
141
+ return MCP_TOOL.test(toolName) ? { tool: toolName, subject: toolName } : undefined;
142
+ }
143
+ function sameCall(a, b) {
144
+ return a.tool === b.tool && a.subject === b.subject;
145
+ }
146
+ function nothing(shown, limitation) {
147
+ return { shown, onceRules: [], alwaysAllowed: false, limitation };
148
+ }
149
+ // ─── Redaction ───────────────────────────────────────────────────────────────
150
+ const MASK = "[redacted]";
151
+ /** `scheme://user:password@host` → `scheme://[redacted]@host`. */
152
+ const URL_CREDENTIALS = /([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi;
153
+ /** Shapes that are a credential wherever they appear. */
154
+ 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;
155
+ /** `Bearer <x>`, `Basic <x>`, `token <x>` (an Authorization header's value). */
156
+ const AUTH_SCHEME = /\b(Bearer|Basic|token)\s+[A-Za-z0-9._~+/=-]{8,}/gi;
157
+ /** `--token x`, `--api-key=x`, `-password x`, `--client-secret "x"`, … */
158
+ 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;
159
+ /** `FOO=…` at the start of a word: an environment assignment, whatever its name. */
160
+ const ENV_ASSIGNMENT = /(^|[\s;&|])([A-Za-z_][A-Za-z0-9_]*)=("[^"]*"|'[^']*'|[^\s;&|]+)/g;
161
+ /**
162
+ * The text with obvious secrets masked, and whether anything was. Errs towards masking: a card is
163
+ * read on a lock screen and stored in a chat, and a masked word costs nothing but a glance.
164
+ * It is not a guarantee — an unusual secret in a positional argument passes — which is one more
165
+ * reason "Always" is never offered for a command this changes.
166
+ */
167
+ function redactSecrets(text) {
168
+ const out = text
169
+ .replace(URL_CREDENTIALS, `$1${MASK}@`)
170
+ .replace(KNOWN_TOKENS, MASK)
171
+ .replace(AUTH_SCHEME, `$1 ${MASK}`)
172
+ .replace(SECRET_FLAG, (_m, flag, sep) => `${flag}${sep}${MASK}`)
173
+ .replace(ENV_ASSIGNMENT, (_m, lead, name) => `${lead}${name}=${MASK}`);
174
+ return { text: out, redacted: out !== text };
175
+ }
176
+ function clip(text, max = 600) {
177
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
178
+ }
179
+ function clipJson(value) {
180
+ try {
181
+ return clip(redactSecrets(JSON.stringify(value)).text, 400);
182
+ }
183
+ catch {
184
+ return "(its parameters could not be displayed)";
185
+ }
186
+ }
@@ -0,0 +1,221 @@
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.addAllowRules = addAllowRules;
37
+ exports.removeAllowRules = removeAllowRules;
38
+ exports.chooseSettingsFile = chooseSettingsFile;
39
+ exports.settingsFileWritable = settingsFileWritable;
40
+ exports.localSettingsFile = localSettingsFile;
41
+ // Adding and removing exact `permissions.allow` entries in ONE Claude Code settings file — and
42
+ // touching nothing else in it.
43
+ //
44
+ // THE ONLY EDIT THIS MODULE MAKES is to append a string to `permissions.allow`, or to take a
45
+ // string BayChat put there back out. `deny`, `ask`, `defaultMode`, `hooks` and every other key are
46
+ // carried through untouched, in the file's own indentation. A file that does not parse, is not a
47
+ // JSON object, or has a `permissions`/`allow` of the wrong shape is REFUSED and left exactly as it
48
+ // was: a permission file we half-understood is not one we get to rewrite.
49
+ //
50
+ // Writes are atomic (a temp file beside the target, then a rename — `nodeSettingsIo`, shared with
51
+ // claude-settings.ts) and keep the file's mode. A symlinked settings file is followed, as Claude
52
+ // Code follows it, and a dangling link is refused.
53
+ //
54
+ // WHICH FILE. The project's `.claude/settings.local.json`: the file Claude Code itself saves "Yes,
55
+ // and don't ask again" into, which it watches and reloads during a session, which applies to this
56
+ // project only, and which Claude Code keeps out of git. See `localSettingsFile` for where exactly.
57
+ const child_process_1 = require("child_process");
58
+ const fs = __importStar(require("fs"));
59
+ const os = __importStar(require("os"));
60
+ const path = __importStar(require("path"));
61
+ const claude_settings_1 = require("../claude-settings");
62
+ const isRecord = (v) => !!v && typeof v === "object" && !Array.isArray(v);
63
+ /** Add each rule that is not already allowed. Returns the ones this call added (none: unchanged).
64
+ * @throws with the file untouched when it cannot be read as a settings object. */
65
+ function addAllowRules(file, rules, io = claude_settings_1.nodeSettingsIo) {
66
+ const edit = readSettings(file, io);
67
+ const allow = allowListOf(edit.settings, edit.target);
68
+ const added = rules.filter((rule, i) => !allow.includes(rule) && rules.indexOf(rule) === i);
69
+ if (added.length === 0)
70
+ return [];
71
+ writeAllow(edit, [...allow, ...added], io);
72
+ return added;
73
+ }
74
+ /** Take each rule out of `permissions.allow`. Idempotent: an absent file or rule is already gone.
75
+ * @throws with the file untouched when it cannot be read as a settings object. */
76
+ function removeAllowRules(file, rules, io = claude_settings_1.nodeSettingsIo) {
77
+ const edit = readSettings(file, io);
78
+ if (edit.text === null)
79
+ return;
80
+ const allow = allowListOf(edit.settings, edit.target);
81
+ const kept = allow.filter((rule) => !rules.includes(rule));
82
+ if (kept.length === allow.length)
83
+ return;
84
+ writeAllow(edit, kept, io);
85
+ }
86
+ function readSettings(file, io) {
87
+ const target = io.realpath(file);
88
+ const text = io.read(target);
89
+ if (text === null || text.trim() === "")
90
+ return { target, text, settings: {} };
91
+ let parsed;
92
+ try {
93
+ parsed = JSON.parse(text);
94
+ }
95
+ catch {
96
+ throw new Error(`${target} is not valid JSON, so BayChat left it untouched.`);
97
+ }
98
+ if (!isRecord(parsed))
99
+ throw new Error(`${target} is not a JSON object, so BayChat left it untouched.`);
100
+ return { target, text, settings: parsed };
101
+ }
102
+ function allowListOf(settings, target) {
103
+ const permissions = settings.permissions;
104
+ if (permissions === undefined)
105
+ return [];
106
+ if (!isRecord(permissions))
107
+ throw new Error(`${target}: "permissions" is not an object, so BayChat left it untouched.`);
108
+ const allow = permissions.allow;
109
+ if (allow === undefined)
110
+ return [];
111
+ if (!Array.isArray(allow) || !allow.every((r) => typeof r === "string")) {
112
+ throw new Error(`${target}: "permissions.allow" is not a list of rules, so BayChat left it untouched.`);
113
+ }
114
+ return allow;
115
+ }
116
+ function writeAllow(edit, allow, io) {
117
+ const permissions = isRecord(edit.settings.permissions) ? edit.settings.permissions : {};
118
+ const next = { ...edit.settings, permissions: { ...permissions, allow } };
119
+ const indent = edit.text ? (0, claude_settings_1.detectIndent)(edit.text) : 2;
120
+ // A new file is private: a once-rule can carry a command's own arguments.
121
+ io.write(edit.target, `${JSON.stringify(next, null, indent)}\n`, io.mode(edit.target) ?? 0o600);
122
+ }
123
+ /**
124
+ * The settings file a rule for a session started in `projectDir` goes into.
125
+ *
126
+ * The project's local file when this user can write it. Otherwise — a repository owned by another
127
+ * user, the first real run's case (a session as `karmen` in `/var/www/baychat`, owned by `manuel`) —
128
+ * the user's own `~/.claude/settings.json`, which Claude Code also reloads live, and the card says
129
+ * so (it then applies to every project). When neither can be written there is nothing to offer, and
130
+ * the card must not draw an Allow button that can only fail.
131
+ */
132
+ function chooseSettingsFile(projectDir, deps = {}) {
133
+ const writable = deps.writable ?? settingsFileWritable;
134
+ const project = (deps.localFile ?? localSettingsFile)(projectDir);
135
+ if (writable(project))
136
+ return { scope: "project", file: project };
137
+ const user = (deps.userFile ?? claude_settings_1.claudeSettingsPath)();
138
+ if (writable(user))
139
+ return { scope: "user", file: user, why: `${project} isn't writable for you` };
140
+ return { scope: "none", why: `neither ${project} nor ${user} is writable for you` };
141
+ }
142
+ /**
143
+ * Could `addAllowRules` write `file` as this user? The write is a temp file beside the real file
144
+ * and a rename, so what has to be writable is its FOLDER (or the nearest folder that exists, which
145
+ * `mkdir` would create it in); an existing file must also be readable. A folder that cannot be
146
+ * looked into is not writable — stat's error, not a missing folder (RECURRING_MISTAKES §26).
147
+ * NEVER THROWS: any doubt is "no".
148
+ */
149
+ function settingsFileWritable(file, io = claude_settings_1.nodeSettingsIo) {
150
+ try {
151
+ const target = io.realpath(file);
152
+ try {
153
+ fs.accessSync(target, fs.constants.R_OK);
154
+ }
155
+ catch (err) {
156
+ if (err.code !== "ENOENT")
157
+ return false;
158
+ }
159
+ let dir = path.dirname(target);
160
+ for (;;) {
161
+ try {
162
+ if (!fs.statSync(dir).isDirectory())
163
+ return false;
164
+ break;
165
+ }
166
+ catch (err) {
167
+ if (err.code !== "ENOENT")
168
+ return false;
169
+ const up = path.dirname(dir);
170
+ if (up === dir)
171
+ return false;
172
+ dir = up;
173
+ }
174
+ }
175
+ fs.accessSync(dir, fs.constants.W_OK | fs.constants.X_OK);
176
+ return true;
177
+ }
178
+ catch {
179
+ return false;
180
+ }
181
+ }
182
+ /**
183
+ * The `.claude/settings.local.json` Claude Code reads for a session started in `projectDir`.
184
+ *
185
+ * Mirrors the settings docs ("Yes, and don't ask again" → `.claude/settings.local.json`): Claude
186
+ * Code keeps that file at the GIT REPOSITORY ROOT (in a worktree, the main checkout's root), and
187
+ * beside `.claude/settings.json` in the starting directory instead when there is no repository,
188
+ * when the root is the home directory, on Windows, or when the root, its `.git` or its `.claude`
189
+ * is not owned by this user.
190
+ */
191
+ function localSettingsFile(projectDir, deps = {}) {
192
+ const home = deps.home ?? os.homedir();
193
+ const platform = deps.platform ?? process.platform;
194
+ const ownedByMe = deps.ownedByMe ?? isOwnedByMe;
195
+ const exists = deps.exists ?? fs.existsSync;
196
+ const commonDir = (deps.gitCommonDir ?? gitCommonDir)(projectDir);
197
+ const gitDir = commonDir ? path.resolve(projectDir, commonDir) : undefined;
198
+ const root = gitDir && path.basename(gitDir) === ".git" ? path.dirname(gitDir) : undefined;
199
+ const usable = root !== undefined &&
200
+ platform !== "win32" &&
201
+ path.resolve(root) !== path.resolve(home) &&
202
+ ownedByMe(root) &&
203
+ ownedByMe(path.join(root, ".git")) &&
204
+ (!exists(path.join(root, ".claude")) || ownedByMe(path.join(root, ".claude")));
205
+ return path.join(usable ? root : projectDir, ".claude", "settings.local.json");
206
+ }
207
+ function gitCommonDir(dir) {
208
+ const r = (0, child_process_1.spawnSync)("git", ["rev-parse", "--git-common-dir"], { cwd: dir, encoding: "utf8", timeout: 5_000 });
209
+ if (r.error || r.status !== 0)
210
+ return undefined;
211
+ return String(r.stdout ?? "").trim() || undefined;
212
+ }
213
+ function isOwnedByMe(p) {
214
+ try {
215
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
216
+ return uid === undefined || fs.statSync(p).uid === uid;
217
+ }
218
+ catch {
219
+ return false;
220
+ }
221
+ }
@@ -0,0 +1,173 @@
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.CONFIRM_WAIT_MS = exports.TRANSCRIPT_WAIT_MS = void 0;
37
+ exports.claudeConfigDir = claudeConfigDir;
38
+ exports.transcriptFileOf = transcriptFileOf;
39
+ exports.verifyDeniedCall = verifyDeniedCall;
40
+ exports.findToolUse = findToolUse;
41
+ // Proof that a denial really happened: the denied call must be in the session's own transcript.
42
+ //
43
+ // WHY. `baychat denied-hook` reads its event from stdin, and stdin is something anybody can
44
+ // write — including the agent, by running `echo '{…}' | baychat denied-hook` itself. A forged
45
+ // event would put a card on the owner's phone claiming a block that never occurred. So the call
46
+ // the event names (`tool_use_id`, `tool_name`, and the command or target) must appear as a tool
47
+ // call the MODEL made, in the transcript Claude Code keeps under `~/.claude/projects/` — a
48
+ // protected path the agent cannot write without a prompt. Before the card when it is already
49
+ // there; otherwise after the owner's yes, which is then taken back when it never appears.
50
+ //
51
+ // It is defence in depth, not the boundary: the owner's answer is. A card the agent somehow did
52
+ // cause still writes nothing unless the owner taps a yes.
53
+ //
54
+ // THE CALL IS NOT THERE YET WHILE THE HOOK RUNS. Measured on Claude Code 2.1.299 (2026-10-14, a
55
+ // file watcher on the transcript): the assistant line holding the denied `tool_use` — and the
56
+ // denial's result — reach the file only AFTER the PermissionDenied hook returns. The hook gave up
57
+ // at 15:32:46.742Z after waiting 45 s; the line landed at 15:32:47.029Z. Same for an MCP call. So
58
+ // waiting longer can never prove a denial up front. The hook now looks only briefly
59
+ // (TRANSCRIPT_WAIT_MS, for a Claude Code that does write first), and an absent call is proven the
60
+ // other way — the event came from the live Claude Code of that very session (live-session.ts) —
61
+ // and checked here AFTERWARDS, by `baychat permissions confirm` (confirm.ts, CONFIRM_WAIT_MS),
62
+ // which takes the rule back out when the call never shows up.
63
+ //
64
+ // For a subagent's call the hook still gets the MAIN transcript (with `agent_id`); its calls are
65
+ // in `<session>/subagents/agent-<id>.jsonl` beside it.
66
+ const fs = __importStar(require("fs"));
67
+ const os = __importStar(require("os"));
68
+ const path = __importStar(require("path"));
69
+ const permission_rule_1 = require("./permission-rule");
70
+ /** The hook's own look at the transcript: brief, because Claude Code 2.1.299 writes the denied call
71
+ * only after the hook returns (above), so this succeeds only on a Claude Code that writes first. */
72
+ exports.TRANSCRIPT_WAIT_MS = 2_000;
73
+ /** How long the deferred confirmation (`baychat permissions confirm`) waits for the call to reach
74
+ * the transcript after the owner's yes. Measured: well under a second after the hook returns. */
75
+ exports.CONFIRM_WAIT_MS = 90_000;
76
+ const TRANSCRIPT_POLL_MS = 250;
77
+ /** Only the end of the file is read: the denied call is the newest thing in it. */
78
+ const TAIL_BYTES = 4 * 1024 * 1024;
79
+ /** Claude Code's config directory: `$CLAUDE_CONFIG_DIR`, else `~/.claude`. */
80
+ function claudeConfigDir(claudeDir) {
81
+ return claudeDir ?? process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
82
+ }
83
+ /** The file a call is written to: the main transcript, or a subagent's own beside it. */
84
+ function transcriptFileOf(transcriptPath, agentId) {
85
+ return agentId
86
+ ? path.join(transcriptPath.slice(0, -".jsonl".length), "subagents", `${agentId.startsWith("agent-") ? agentId : `agent-${agentId}`}.jsonl`)
87
+ : transcriptPath;
88
+ }
89
+ /** Did the model really make this call in this session? NEVER THROWS. */
90
+ async function verifyDeniedCall(ref, deps = {}) {
91
+ const now = deps.now ?? Date.now;
92
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
93
+ const realpath = deps.realpath ?? ((f) => fs.realpathSync(f));
94
+ const readTail = deps.readTail ?? readFileTail;
95
+ try {
96
+ const projects = realpath(path.join(claudeConfigDir(deps.claudeDir), "projects"));
97
+ const wanted = ref.call ?? (0, permission_rule_1.callKeyOf)(ref.toolName, ref.toolInput);
98
+ if (!ref.toolUseId || !wanted)
99
+ return { ok: false, why: "the event names no call that can be checked" };
100
+ if (!path.isAbsolute(ref.transcriptPath) || !ref.transcriptPath.endsWith(".jsonl")) {
101
+ return { ok: false, why: "the event carries no transcript" };
102
+ }
103
+ const file = transcriptFileOf(ref.transcriptPath, ref.agentId);
104
+ const giveUpAt = now() + (deps.waitMs ?? exports.TRANSCRIPT_WAIT_MS);
105
+ for (;;) {
106
+ let real;
107
+ try {
108
+ real = realpath(file);
109
+ }
110
+ catch {
111
+ real = undefined; // not written yet
112
+ }
113
+ if (real !== undefined && !real.startsWith(projects + path.sep)) {
114
+ return { ok: false, why: "the transcript is not in Claude Code's own folder" };
115
+ }
116
+ const tail = real === undefined ? undefined : readTail(real);
117
+ const found = tail === undefined ? undefined : findToolUse(tail, ref.toolUseId);
118
+ if (found) {
119
+ const key = (0, permission_rule_1.callKeyOf)(found.name, found.input);
120
+ return found.name === ref.toolName && key !== undefined && (0, permission_rule_1.sameCall)(key, wanted)
121
+ ? { ok: true }
122
+ : { ok: false, why: "the transcript's call is not the one the event names" };
123
+ }
124
+ if (now() >= giveUpAt)
125
+ return { ok: false, why: "the call is not in the session's transcript", absent: true };
126
+ await sleep(TRANSCRIPT_POLL_MS);
127
+ }
128
+ }
129
+ catch (err) {
130
+ return { ok: false, why: `the transcript could not be read (${err instanceof Error ? err.message : String(err)})` };
131
+ }
132
+ }
133
+ /** The model's tool call with this id, from transcript JSONL text. */
134
+ function findToolUse(text, toolUseId) {
135
+ for (const line of text.split("\n")) {
136
+ if (!line.includes(toolUseId))
137
+ continue;
138
+ let entry;
139
+ try {
140
+ entry = JSON.parse(line);
141
+ }
142
+ catch {
143
+ continue; // the cut-off first line of a tail, or a line being written
144
+ }
145
+ const e = entry;
146
+ if (e.type !== "assistant" || !Array.isArray(e.message?.content))
147
+ continue;
148
+ for (const block of e.message.content) {
149
+ if (block?.type === "tool_use" && block.id === toolUseId && typeof block.name === "string") {
150
+ return { name: block.name, input: block.input };
151
+ }
152
+ }
153
+ }
154
+ return undefined;
155
+ }
156
+ function readFileTail(file) {
157
+ let fd;
158
+ try {
159
+ fd = fs.openSync(file, "r");
160
+ const size = fs.fstatSync(fd).size;
161
+ const length = Math.min(size, TAIL_BYTES);
162
+ const buf = Buffer.alloc(length);
163
+ fs.readSync(fd, buf, 0, length, size - length);
164
+ return buf.toString("utf8");
165
+ }
166
+ catch {
167
+ return undefined;
168
+ }
169
+ finally {
170
+ if (fd !== undefined)
171
+ fs.closeSync(fd);
172
+ }
173
+ }