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.
@@ -0,0 +1,160 @@
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.localSettingsFile = localSettingsFile;
39
+ // Adding and removing exact `permissions.allow` entries in ONE Claude Code settings file — and
40
+ // touching nothing else in it.
41
+ //
42
+ // THE ONLY EDIT THIS MODULE MAKES is to append a string to `permissions.allow`, or to take a
43
+ // string BayChat put there back out. `deny`, `ask`, `defaultMode`, `hooks` and every other key are
44
+ // carried through untouched, in the file's own indentation. A file that does not parse, is not a
45
+ // JSON object, or has a `permissions`/`allow` of the wrong shape is REFUSED and left exactly as it
46
+ // was: a permission file we half-understood is not one we get to rewrite.
47
+ //
48
+ // Writes are atomic (a temp file beside the target, then a rename — `nodeSettingsIo`, shared with
49
+ // claude-settings.ts) and keep the file's mode. A symlinked settings file is followed, as Claude
50
+ // Code follows it, and a dangling link is refused.
51
+ //
52
+ // WHICH FILE. The project's `.claude/settings.local.json`: the file Claude Code itself saves "Yes,
53
+ // and don't ask again" into, which it watches and reloads during a session, which applies to this
54
+ // project only, and which Claude Code keeps out of git. See `localSettingsFile` for where exactly.
55
+ const child_process_1 = require("child_process");
56
+ const fs = __importStar(require("fs"));
57
+ const os = __importStar(require("os"));
58
+ const path = __importStar(require("path"));
59
+ const claude_settings_1 = require("../claude-settings");
60
+ const isRecord = (v) => !!v && typeof v === "object" && !Array.isArray(v);
61
+ /** Add each rule that is not already allowed. Returns the ones this call added (none: unchanged).
62
+ * @throws with the file untouched when it cannot be read as a settings object. */
63
+ function addAllowRules(file, rules, io = claude_settings_1.nodeSettingsIo) {
64
+ const edit = readSettings(file, io);
65
+ const allow = allowListOf(edit.settings, edit.target);
66
+ const added = rules.filter((rule, i) => !allow.includes(rule) && rules.indexOf(rule) === i);
67
+ if (added.length === 0)
68
+ return [];
69
+ writeAllow(edit, [...allow, ...added], io);
70
+ return added;
71
+ }
72
+ /** Take each rule out of `permissions.allow`. Idempotent: an absent file or rule is already gone.
73
+ * @throws with the file untouched when it cannot be read as a settings object. */
74
+ function removeAllowRules(file, rules, io = claude_settings_1.nodeSettingsIo) {
75
+ const edit = readSettings(file, io);
76
+ if (edit.text === null)
77
+ return;
78
+ const allow = allowListOf(edit.settings, edit.target);
79
+ const kept = allow.filter((rule) => !rules.includes(rule));
80
+ if (kept.length === allow.length)
81
+ return;
82
+ writeAllow(edit, kept, io);
83
+ }
84
+ function readSettings(file, io) {
85
+ const target = io.realpath(file);
86
+ const text = io.read(target);
87
+ if (text === null || text.trim() === "")
88
+ return { target, text, settings: {} };
89
+ let parsed;
90
+ try {
91
+ parsed = JSON.parse(text);
92
+ }
93
+ catch {
94
+ throw new Error(`${target} is not valid JSON, so BayChat left it untouched.`);
95
+ }
96
+ if (!isRecord(parsed))
97
+ throw new Error(`${target} is not a JSON object, so BayChat left it untouched.`);
98
+ return { target, text, settings: parsed };
99
+ }
100
+ function allowListOf(settings, target) {
101
+ const permissions = settings.permissions;
102
+ if (permissions === undefined)
103
+ return [];
104
+ if (!isRecord(permissions))
105
+ throw new Error(`${target}: "permissions" is not an object, so BayChat left it untouched.`);
106
+ const allow = permissions.allow;
107
+ if (allow === undefined)
108
+ return [];
109
+ if (!Array.isArray(allow) || !allow.every((r) => typeof r === "string")) {
110
+ throw new Error(`${target}: "permissions.allow" is not a list of rules, so BayChat left it untouched.`);
111
+ }
112
+ return allow;
113
+ }
114
+ function writeAllow(edit, allow, io) {
115
+ const permissions = isRecord(edit.settings.permissions) ? edit.settings.permissions : {};
116
+ const next = { ...edit.settings, permissions: { ...permissions, allow } };
117
+ const indent = edit.text ? (0, claude_settings_1.detectIndent)(edit.text) : 2;
118
+ // A new file is private: a once-rule can carry a command's own arguments.
119
+ io.write(edit.target, `${JSON.stringify(next, null, indent)}\n`, io.mode(edit.target) ?? 0o600);
120
+ }
121
+ /**
122
+ * The `.claude/settings.local.json` Claude Code reads for a session started in `projectDir`.
123
+ *
124
+ * Mirrors the settings docs ("Yes, and don't ask again" → `.claude/settings.local.json`): Claude
125
+ * Code keeps that file at the GIT REPOSITORY ROOT (in a worktree, the main checkout's root), and
126
+ * beside `.claude/settings.json` in the starting directory instead when there is no repository,
127
+ * when the root is the home directory, on Windows, or when the root, its `.git` or its `.claude`
128
+ * is not owned by this user.
129
+ */
130
+ function localSettingsFile(projectDir, deps = {}) {
131
+ const home = deps.home ?? os.homedir();
132
+ const platform = deps.platform ?? process.platform;
133
+ const ownedByMe = deps.ownedByMe ?? isOwnedByMe;
134
+ const exists = deps.exists ?? fs.existsSync;
135
+ const commonDir = (deps.gitCommonDir ?? gitCommonDir)(projectDir);
136
+ const gitDir = commonDir ? path.resolve(projectDir, commonDir) : undefined;
137
+ const root = gitDir && path.basename(gitDir) === ".git" ? path.dirname(gitDir) : undefined;
138
+ const usable = root !== undefined &&
139
+ platform !== "win32" &&
140
+ path.resolve(root) !== path.resolve(home) &&
141
+ ownedByMe(root) &&
142
+ ownedByMe(path.join(root, ".git")) &&
143
+ (!exists(path.join(root, ".claude")) || ownedByMe(path.join(root, ".claude")));
144
+ return path.join(usable ? root : projectDir, ".claude", "settings.local.json");
145
+ }
146
+ function gitCommonDir(dir) {
147
+ const r = (0, child_process_1.spawnSync)("git", ["rev-parse", "--git-common-dir"], { cwd: dir, encoding: "utf8", timeout: 5_000 });
148
+ if (r.error || r.status !== 0)
149
+ return undefined;
150
+ return String(r.stdout ?? "").trim() || undefined;
151
+ }
152
+ function isOwnedByMe(p) {
153
+ try {
154
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
155
+ return uid === undefined || fs.statSync(p).uid === uid;
156
+ }
157
+ catch {
158
+ return false;
159
+ }
160
+ }
@@ -0,0 +1,150 @@
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.TRANSCRIPT_WAIT_MS = void 0;
37
+ exports.verifyDeniedCall = verifyDeniedCall;
38
+ exports.findToolUse = findToolUse;
39
+ // Proof that a denial really happened: the denied call must be in the session's own transcript.
40
+ //
41
+ // WHY. `baychat denied-hook` reads its event from stdin, and stdin is something anybody can
42
+ // write — including the agent, by running `echo '{…}' | baychat denied-hook` itself. A forged
43
+ // event would put a card on the owner's phone claiming a block that never occurred. So before
44
+ // anything is posted, the call the event names (`tool_use_id`, `tool_name`, and the command or
45
+ // target) must appear as a tool call the MODEL made, in the transcript Claude Code keeps under
46
+ // `~/.claude/projects/` — a protected path the agent cannot write without a prompt.
47
+ //
48
+ // It is defence in depth, not the boundary: the owner's answer is. A card the agent somehow did
49
+ // cause still writes nothing unless the owner taps a yes.
50
+ //
51
+ // THE TRANSCRIPT CAN LAG. The hooks reference says it "is written asynchronously and may lag the
52
+ // in-memory conversation", so a missing call is polled for, briefly, before it counts as absent.
53
+ // For a subagent's call the hook still gets the MAIN transcript (with `agent_id`); its calls are
54
+ // in `<session>/subagents/agent-<id>.jsonl` beside it.
55
+ const fs = __importStar(require("fs"));
56
+ const os = __importStar(require("os"));
57
+ const path = __importStar(require("path"));
58
+ const permission_rule_1 = require("./permission-rule");
59
+ /** How long a call may take to reach the transcript before it counts as never made. */
60
+ exports.TRANSCRIPT_WAIT_MS = 10_000;
61
+ const TRANSCRIPT_POLL_MS = 250;
62
+ /** Only the end of the file is read: the denied call is the newest thing in it. */
63
+ const TAIL_BYTES = 4 * 1024 * 1024;
64
+ /** Did the model really make this call in this session? NEVER THROWS. */
65
+ async function verifyDeniedCall(ref, deps = {}) {
66
+ const now = deps.now ?? Date.now;
67
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
68
+ const realpath = deps.realpath ?? ((f) => fs.realpathSync(f));
69
+ const readTail = deps.readTail ?? readFileTail;
70
+ try {
71
+ const projects = realpath(path.join(deps.claudeDir ?? process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"), "projects"));
72
+ const wanted = (0, permission_rule_1.callKeyOf)(ref.toolName, ref.toolInput);
73
+ if (!ref.toolUseId || !wanted)
74
+ return { ok: false, why: "the event names no call that can be checked" };
75
+ if (!path.isAbsolute(ref.transcriptPath) || !ref.transcriptPath.endsWith(".jsonl")) {
76
+ return { ok: false, why: "the event carries no transcript" };
77
+ }
78
+ const file = ref.agentId
79
+ ? path.join(ref.transcriptPath.slice(0, -".jsonl".length), "subagents", `${ref.agentId.startsWith("agent-") ? ref.agentId : `agent-${ref.agentId}`}.jsonl`)
80
+ : ref.transcriptPath;
81
+ const giveUpAt = now() + exports.TRANSCRIPT_WAIT_MS;
82
+ for (;;) {
83
+ let real;
84
+ try {
85
+ real = realpath(file);
86
+ }
87
+ catch {
88
+ real = undefined; // not written yet
89
+ }
90
+ if (real !== undefined && !real.startsWith(projects + path.sep)) {
91
+ return { ok: false, why: "the transcript is not in Claude Code's own folder" };
92
+ }
93
+ const tail = real === undefined ? undefined : readTail(real);
94
+ const found = tail === undefined ? undefined : findToolUse(tail, ref.toolUseId);
95
+ if (found) {
96
+ const key = (0, permission_rule_1.callKeyOf)(found.name, found.input);
97
+ return found.name === ref.toolName && key !== undefined && (0, permission_rule_1.sameCall)(key, wanted)
98
+ ? { ok: true }
99
+ : { ok: false, why: "the transcript's call is not the one the event names" };
100
+ }
101
+ if (now() >= giveUpAt)
102
+ return { ok: false, why: "the call is not in the session's transcript" };
103
+ await sleep(TRANSCRIPT_POLL_MS);
104
+ }
105
+ }
106
+ catch (err) {
107
+ return { ok: false, why: `the transcript could not be read (${err instanceof Error ? err.message : String(err)})` };
108
+ }
109
+ }
110
+ /** The model's tool call with this id, from transcript JSONL text. */
111
+ function findToolUse(text, toolUseId) {
112
+ for (const line of text.split("\n")) {
113
+ if (!line.includes(toolUseId))
114
+ continue;
115
+ let entry;
116
+ try {
117
+ entry = JSON.parse(line);
118
+ }
119
+ catch {
120
+ continue; // the cut-off first line of a tail, or a line being written
121
+ }
122
+ const e = entry;
123
+ if (e.type !== "assistant" || !Array.isArray(e.message?.content))
124
+ continue;
125
+ for (const block of e.message.content) {
126
+ if (block?.type === "tool_use" && block.id === toolUseId && typeof block.name === "string") {
127
+ return { name: block.name, input: block.input };
128
+ }
129
+ }
130
+ }
131
+ return undefined;
132
+ }
133
+ function readFileTail(file) {
134
+ let fd;
135
+ try {
136
+ fd = fs.openSync(file, "r");
137
+ const size = fs.fstatSync(fd).size;
138
+ const length = Math.min(size, TAIL_BYTES);
139
+ const buf = Buffer.alloc(length);
140
+ fs.readSync(fd, buf, 0, length, size - length);
141
+ return buf.toString("utf8");
142
+ }
143
+ catch {
144
+ return undefined;
145
+ }
146
+ finally {
147
+ if (fd !== undefined)
148
+ fs.closeSync(fd);
149
+ }
150
+ }
@@ -0,0 +1,329 @@
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.RELOAD_SETTLE_MS = void 0;
37
+ exports.retryOutput = retryOutput;
38
+ exports.composeDeniedQuestion = composeDeniedQuestion;
39
+ exports.offeredMeanings = offeredMeanings;
40
+ exports.decideDenied = decideDenied;
41
+ exports.cmdDeniedHook = cmdDeniedHook;
42
+ // `baychat denied-hook` — the Claude Code PermissionDenied hook that turns an auto-mode block into
43
+ // a card on the owner's phone, and the owner's "yes" into the one exact permission rule that lets
44
+ // the session run that call.
45
+ //
46
+ // WHY THIS EXISTS. Auto mode's classifier HARD-denies some calls — `npx eas-cli build …`, a release
47
+ // script, `npm publish`. A hard denial draws no prompt, so approve-hook (a PermissionRequest hook)
48
+ // never runs, and the only way through was to walk to the computer and type `/permissions`. Karmen:
49
+ // "We have to have this possibility as people have to use BayChat also when not near computer".
50
+ // See docs/features/DENIED_COMMAND_CARD.md.
51
+ //
52
+ // WHAT CLAUDE CODE GIVES US (hooks reference, "PermissionDenied"): the event fires only in auto
53
+ // mode, after the denial; "Exit code and stderr are ignored because the denial already occurred";
54
+ // the one output read is `hookSpecificOutput.retry: true`, which "adds a message to the
55
+ // conversation telling the model it may retry the tool call" — it does not reverse the denial. So
56
+ // the retry only succeeds because, before printing it, THIS PROCESS (never the agent) writes an
57
+ // allow rule that matches exactly that call into the project's `.claude/settings.local.json`,
58
+ // which Claude Code reloads live and checks before the classifier.
59
+ //
60
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
61
+ // FAIL-CLOSED HERE MEANS "PRINT NOTHING".
62
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
63
+ //
64
+ // Unlike approve-hook, silence is safe: no output leaves the denial standing. Every abnormal path
65
+ // — no credential, no transcript proof, BayChat unreachable, an unanswered card, a settings file
66
+ // we cannot parse, an exception — prints nothing and writes no rule. A rule is written only after
67
+ // the card's owner tapped a yes, and `retry` is printed only after the rule was written.
68
+ //
69
+ // THE PROCESS BLOCKS THE SESSION WHILE IT WAITS, like approve-hook: hooks are awaited, so the
70
+ // session stops until the owner answers or our deadline passes. That is the reliable shape: the
71
+ // rule is in place before the model hears "you may retry", and the retry happens at once, in the
72
+ // same turn. Being killed by the handler timeout is harmless here (the denial stands), but the
73
+ // same 3600 s / 3900 s pair as approve-hook keeps our own deadline first, so the card is withdrawn
74
+ // rather than left looking answerable.
75
+ const child_process_1 = require("child_process");
76
+ const fs = __importStar(require("fs"));
77
+ const os = __importStar(require("os"));
78
+ const path = __importStar(require("path"));
79
+ const approve_hook_1 = require("./approve-hook");
80
+ const approval_wait_1 = require("./approval-wait");
81
+ const config_1 = require("./config");
82
+ const hook_stdin_1 = require("./hook-stdin");
83
+ const grants_1 = require("./denied/grants");
84
+ const permission_rule_1 = require("./denied/permission-rule");
85
+ const settings_rules_1 = require("./denied/settings-rules");
86
+ const transcript_1 = require("./denied/transcript");
87
+ function retryOutput() {
88
+ return { hookSpecificOutput: { hookEventName: "PermissionDenied", retry: true } };
89
+ }
90
+ // ─── The card ────────────────────────────────────────────────────────────────
91
+ const LOG_LABEL = "baychat denied-hook";
92
+ const LABEL = {
93
+ once: "Allow once",
94
+ always: "Always allow this exact command",
95
+ no: "No",
96
+ };
97
+ /** The single button of a card that only informs: nothing can be allowed from BayChat. */
98
+ const NOTICE_OPTIONS = ["OK"];
99
+ /** The card's own limits (apps/api/src/lib/cards.ts), used only to TRIM. */
100
+ const QUESTION_MAX = 2000;
101
+ const SHOWN_MAX = 900;
102
+ /**
103
+ * Give Claude Code's settings watcher this long to load the new rule before the model is told to
104
+ * retry. Claude Code does not say how fast its watcher is; this is a margin, and a retry that
105
+ * still meets the old settings is handled (see `MAX_SILENT_RETRIES`).
106
+ */
107
+ exports.RELOAD_SETTLE_MS = 1500;
108
+ /** A call denied AGAIN while a grant already covers it is retried this many times without a new
109
+ * card (the watcher was slower than RELOAD_SETTLE_MS). After that the rule evidently does not
110
+ * match how Claude Code reads the call, and the owner is told so. */
111
+ const MAX_SILENT_RETRIES = 1;
112
+ /** The classifier gave no verdict: Claude Code ignores `retry` for these, and a rule written for a
113
+ * transient failure would outlive it. Texts from the hooks reference. */
114
+ function isNoVerdict(reason) {
115
+ return reason.startsWith("Auto mode could not evaluate") || reason === "Classifier unavailable";
116
+ }
117
+ /** The card's question. A person decides from this text alone, on a lock screen. */
118
+ function composeDeniedQuestion(q) {
119
+ const shown = q.plan.shown.length <= SHOWN_MAX ? q.plan.shown : `${q.plan.shown.slice(0, SHOWN_MAX - 1)}…`;
120
+ const offers = q.plan.onceRules.length
121
+ ? [
122
+ `Allow once: BayChat adds ${q.plan.onceRules.length === 1 ? "this exact rule" : "these exact rules"} to ` +
123
+ `${q.settingsFile} for the retry, then takes ${q.plan.onceRules.length === 1 ? "it" : "them"} out.`,
124
+ q.plan.alwaysAllowed ? "Always: BayChat keeps the rule there (`baychat permissions list` shows it)." : "",
125
+ ]
126
+ : [];
127
+ const lines = [
128
+ `${q.who} on ${q.host} was blocked by Claude Code's auto mode:`,
129
+ "",
130
+ shown,
131
+ "",
132
+ q.reason ? `Reason: ${q.reason}` : "",
133
+ q.cwd ? `Working directory: ${q.cwd}` : "",
134
+ "",
135
+ ...offers,
136
+ q.plan.limitation,
137
+ ];
138
+ const text = lines.filter((l) => l !== undefined).join("\n").replace(/\n{3,}/g, "\n\n").trim();
139
+ return text.length <= QUESTION_MAX ? text : `${text.slice(0, QUESTION_MAX - 1)}…`;
140
+ }
141
+ /** The buttons for a plan: every option it allows, and always a way to say no. */
142
+ function offeredMeanings(plan) {
143
+ if (plan.onceRules.length === 0)
144
+ return [];
145
+ return plan.alwaysAllowed ? ["once", "always", "no"] : ["once", "no"];
146
+ }
147
+ function parseArgs(argv) {
148
+ const at = (name) => {
149
+ const i = argv.indexOf(name);
150
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
151
+ };
152
+ const rawTimeout = Number(at("--timeout"));
153
+ return {
154
+ session: at("--session"),
155
+ fromClaude: argv.includes("--session-from-claude"),
156
+ waitSeconds: Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : approve_hook_1.DEFAULT_WAIT_SECONDS,
157
+ };
158
+ }
159
+ const str = (v) => (typeof v === "string" ? v : "");
160
+ /**
161
+ * Turn one PermissionDenied event into `retry` — or into nothing, which leaves the denial
162
+ * standing. NEVER THROWS. `retry` is returned only after the owner allowed the call and the rule
163
+ * that matches it is written.
164
+ */
165
+ async function decideDenied(rawStdin, argv = [], deps = {}) {
166
+ const now = deps.now ?? Date.now;
167
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
168
+ const log = deps.log ?? ((line) => process.stderr.write(`${line}\n`));
169
+ try {
170
+ const opts = parseArgs(argv);
171
+ let input;
172
+ try {
173
+ const parsed = JSON.parse(rawStdin);
174
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
175
+ throw new Error("not an object");
176
+ input = parsed;
177
+ }
178
+ catch {
179
+ log(`${LOG_LABEL}: the event could not be read from stdin; the denial stands.`);
180
+ return null;
181
+ }
182
+ if (input.hook_event_name !== "PermissionDenied") {
183
+ log(`${LOG_LABEL}: not a PermissionDenied event (${String(input.hook_event_name)}); the denial stands.`);
184
+ return null;
185
+ }
186
+ const reason = str(input.reason);
187
+ if (isNoVerdict(reason)) {
188
+ log(`${LOG_LABEL}: auto mode gave no verdict ("${reason}"), so Claude Code would ignore a retry.`);
189
+ return null;
190
+ }
191
+ const toolName = str(input.tool_name);
192
+ const claudeSessionId = str(input.session_id);
193
+ const call = (0, permission_rule_1.callKeyOf)(toolName, input.tool_input);
194
+ if (!toolName || !claudeSessionId) {
195
+ log(`${LOG_LABEL}: the event has no tool or session (keys: ${Object.keys(input).join(", ")}).`);
196
+ return null;
197
+ }
198
+ const session = opts.session ?? (opts.fromClaude ? (0, approve_hook_1.sessionFromClaude)(deps.claude) : undefined);
199
+ if (opts.fromClaude && session === undefined) {
200
+ log(`${LOG_LABEL}: this session has not joined BayChat yet; the denial stands.`);
201
+ return null;
202
+ }
203
+ const device = (deps.loadDevice ?? config_1.loadDeviceCredentials)();
204
+ if (!device) {
205
+ log(`${LOG_LABEL}: this machine is not logged in to BayChat (\`baychat login\`); the denial stands.`);
206
+ return null;
207
+ }
208
+ // Only a call the model really made may put a card on anybody's phone.
209
+ const proof = await (deps.verify ?? transcript_1.verifyDeniedCall)({
210
+ transcriptPath: str(input.transcript_path),
211
+ agentId: str(input.agent_id) || undefined,
212
+ toolUseId: str(input.tool_use_id),
213
+ toolName,
214
+ toolInput: input.tool_input,
215
+ });
216
+ if (!proof.ok) {
217
+ log(`${LOG_LABEL}: ${proof.why}; no card was posted and the denial stands.`);
218
+ return null;
219
+ }
220
+ const host = (deps.hostname ?? os.hostname)();
221
+ const who = session ? `Session "${session}"` : "A Claude Code session";
222
+ const waitDeps = { now, sleep, log };
223
+ // A card that only informs: posted, never waited on (its deadline is already now).
224
+ const notify = (question) => (0, approval_wait_1.askOwnerAndWait)(device, { session, question, options: NOTICE_OPTIONS }, now(), waitDeps, LOG_LABEL);
225
+ // ── Already allowed, and denied again: the reload race, or a rule that does not match.
226
+ const covering = call ? (0, grants_1.coveringGrant)(claudeSessionId, call, now()) : undefined;
227
+ if (covering) {
228
+ if (covering.retries < MAX_SILENT_RETRIES) {
229
+ await (0, grants_1.noteRetry)(covering.id);
230
+ await sleep(exports.RELOAD_SETTLE_MS * 2);
231
+ return retryOutput();
232
+ }
233
+ if (covering.kind === "once")
234
+ await (0, grants_1.revokeGrant)(covering.id);
235
+ await notify(`${who} on ${host} is still blocked although you allowed it:\n\n${(0, permission_rule_1.planDeniedCall)(toolName, input.tool_input).shown}\n\n` +
236
+ "Claude Code did not accept the rule BayChat wrote for it. Approve it at the computer " +
237
+ "(/permissions → Recently denied).");
238
+ return null;
239
+ }
240
+ const projectDir = deps.projectDir ?? (process.env.CLAUDE_PROJECT_DIR || str(input.cwd));
241
+ if (!projectDir || !path.isAbsolute(projectDir)) {
242
+ log(`${LOG_LABEL}: no project directory to keep a rule in; the denial stands.`);
243
+ return null;
244
+ }
245
+ const settingsFile = (deps.settingsFileFor ?? settings_rules_1.localSettingsFile)(projectDir);
246
+ const plan = (0, permission_rule_1.planDeniedCall)(toolName, input.tool_input);
247
+ const question = composeDeniedQuestion({ who, host, plan, reason, cwd: str(input.cwd), settingsFile });
248
+ const meanings = offeredMeanings(plan);
249
+ if (meanings.length === 0) {
250
+ // Nothing can be allowed from BayChat; the owner still learns why the session stopped.
251
+ await notify(question);
252
+ return null;
253
+ }
254
+ const deadline = now() + opts.waitSeconds * 1000;
255
+ const outcome = await (0, approval_wait_1.askOwnerAndWait)(device, {
256
+ session,
257
+ question,
258
+ options: meanings.map((m) => LABEL[m]),
259
+ guardedOptions: meanings.flatMap((m, i) => (m === "always" ? [i] : [])),
260
+ }, deadline, waitDeps, LOG_LABEL);
261
+ if (outcome.kind === "deadline") {
262
+ await (0, approval_wait_1.withdrawOwnerQuestion)(device, outcome.requestId, log);
263
+ log(`${LOG_LABEL}: nobody answered within ${opts.waitSeconds}s; the card was withdrawn and the denial stands.`);
264
+ return null;
265
+ }
266
+ if (outcome.kind !== "answered") {
267
+ log(`${LOG_LABEL}: ${outcome.kind === "failed" ? outcome.reason : outcome.kind}; the denial stands.`);
268
+ return null;
269
+ }
270
+ const meaning = (outcome.index !== null ? meanings[outcome.index] : undefined) ?? "no";
271
+ if (meaning === "no" || !plan.call)
272
+ return null;
273
+ let grant;
274
+ try {
275
+ grant = await (0, grants_1.grantRules)({
276
+ kind: meaning,
277
+ rules: plan.onceRules,
278
+ settingsFile,
279
+ call: plan.call,
280
+ shown: plan.shown,
281
+ claudeSessionId,
282
+ session,
283
+ }, now());
284
+ }
285
+ catch (err) {
286
+ log(`${LOG_LABEL}: the rule could not be written (${(0, approval_wait_1.reasonOf)(err)}); the denial stands.`);
287
+ await notify(`BayChat could not save the rule you allowed, so ${who.toLowerCase()} stays blocked:\n\n${(0, approval_wait_1.reasonOf)(err)}`);
288
+ return null;
289
+ }
290
+ if (grant.kind === "once" && grant.expiresAt) {
291
+ (deps.scheduleSweep ?? scheduleSweep)(Date.parse(grant.expiresAt));
292
+ }
293
+ await sleep(exports.RELOAD_SETTLE_MS);
294
+ return retryOutput();
295
+ }
296
+ catch (err) {
297
+ log(`${LOG_LABEL}: unexpected failure (${(0, approval_wait_1.reasonOf)(err)}); the denial stands.`);
298
+ return null;
299
+ }
300
+ }
301
+ /**
302
+ * Start `baychat permissions sweep --at <ms>` detached, so a once-rule is taken back at its TTL
303
+ * even when the retried call never reports back and no other hook runs. Best effort: the
304
+ * after-hook and every later hook run sweep too.
305
+ */
306
+ function scheduleSweep(atMs) {
307
+ try {
308
+ const entry = fs.realpathSync(process.argv[1] ?? "");
309
+ const child = (0, child_process_1.spawn)(process.execPath, [entry, "permissions", "sweep", "--at", String(atMs)], {
310
+ detached: true,
311
+ stdio: "ignore",
312
+ });
313
+ child.unref();
314
+ }
315
+ catch (err) {
316
+ process.stderr.write(`${LOG_LABEL}: could not schedule the once-rule's removal (${(0, approval_wait_1.reasonOf)(err)}).\n`);
317
+ }
318
+ }
319
+ /**
320
+ * `baychat denied-hook [--session <name> | --session-from-claude] [--timeout <seconds>]`
321
+ *
322
+ * Always returns 0 (Claude Code ignores this event's exit code). Prints `retry` or nothing.
323
+ */
324
+ async function cmdDeniedHook(argv = [], stdin = process.stdin) {
325
+ const output = await decideDenied(await (0, hook_stdin_1.readStdin)(stdin), argv);
326
+ if (output)
327
+ process.stdout.write(`${JSON.stringify(output)}\n`);
328
+ return 0;
329
+ }
@@ -97,6 +97,9 @@ async function currentDoctorEnv() {
97
97
  device: (0, config_1.loadDeviceCredentials)(),
98
98
  relay: await (0, commands_1.tryRelayStatus)(),
99
99
  node: { version: process.version, webSocket: (0, ws_1.hasBuiltInWebSocket)() },
100
+ // APPDATA, LOCALAPPDATA and a directory lister: without them a Windows report cannot
101
+ // see Claude Desktop's config at all, Store copy or classic.
102
+ pathEnv: (0, client_paths_1.currentPathEnv)(),
100
103
  targets: registry.all(),
101
104
  readText(filePath) {
102
105
  try {