baychat 0.30.0 → 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 (38) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +36 -3
  3. package/dist/approval-wait.js +46 -33
  4. package/dist/approve-hook.js +35 -1
  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 +6 -1
  13. package/dist/denied/after-hook.js +8 -2
  14. package/dist/denied/confirm.js +131 -0
  15. package/dist/denied/grants.js +60 -2
  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/permission-rule.js +9 -6
  20. package/dist/denied/settings-rules.js +61 -0
  21. package/dist/denied/transcript.js +39 -16
  22. package/dist/denied-hook.js +132 -30
  23. package/dist/doctor-command.js +7 -1
  24. package/dist/doctor.js +28 -0
  25. package/dist/main.js +11 -2
  26. package/dist/permissions-command.js +18 -1
  27. package/dist/refresh-clients.js +36 -0
  28. package/dist/relay/acp/runner.js +4 -0
  29. package/dist/relay/commands.js +19 -3
  30. package/dist/relay/daemon.js +37 -1
  31. package/dist/relay/launcher/loop.js +4 -1
  32. package/dist/relay/self-restart.js +149 -0
  33. package/dist/relay/terminal-pane.js +74 -2
  34. package/dist/self-upgrade.js +92 -27
  35. package/dist/start-command.js +15 -1
  36. package/dist/update-command.js +51 -31
  37. package/dist/waiting-cards.js +107 -0
  38. package/package.json +4 -1
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.ONCE_TTL_MS = void 0;
36
+ exports.UNCONFIRMED_STALE_MS = exports.ONCE_TTL_MS = void 0;
37
37
  exports.grantRules = grantRules;
38
38
  exports.listGrants = listGrants;
39
39
  exports.coveringGrant = coveringGrant;
@@ -42,6 +42,12 @@ exports.revokeGrant = revokeGrant;
42
42
  exports.consumeOnceGrants = consumeOnceGrants;
43
43
  exports.sweepExpiredGrants = sweepExpiredGrants;
44
44
  exports.hasOnceGrants = hasOnceGrants;
45
+ exports.hasGrantsToSweep = hasGrantsToSweep;
46
+ exports.findGrant = findGrant;
47
+ exports.markConfirmed = markConfirmed;
48
+ exports.isStaleUnconfirmed = isStaleUnconfirmed;
49
+ exports.hasStaleUnconfirmed = hasStaleUnconfirmed;
50
+ exports.sweepStaleUnconfirmed = sweepStaleUnconfirmed;
45
51
  // The permission rules BayChat itself wrote on this computer, and the one owner of every change to
46
52
  // them (`~/.baychat/permission-grants.json`, 0600).
47
53
  //
@@ -61,10 +67,15 @@ const fs = __importStar(require("fs"));
61
67
  const path = __importStar(require("path"));
62
68
  const config_1 = require("../config");
63
69
  const permission_rule_1 = require("./permission-rule");
70
+ const transcript_1 = require("./transcript");
64
71
  const settings_rules_1 = require("./settings-rules");
65
72
  /** How long a once-rule may outlive its card when the retried call never reports back. The retry
66
73
  * starts within seconds; this only bounds a rule left behind by a session that went away. */
67
74
  exports.ONCE_TTL_MS = 5 * 60_000;
75
+ /** An unconfirmed grant older than this lost its confirmation (the child was killed, or never
76
+ * started) and is taken back by the next hook run or sweep. The confirmation's own wait
77
+ * (CONFIRM_WAIT_MS) plus room for its start-up. */
78
+ exports.UNCONFIRMED_STALE_MS = transcript_1.CONFIRM_WAIT_MS + 30_000;
68
79
  const LOCK_WAIT_MS = 5_000;
69
80
  const LOCK_POLL_MS = 50;
70
81
  const STALE_LOCK_MS = 30_000;
@@ -85,6 +96,7 @@ async function grantRules(request, now = Date.now()) {
85
96
  kind: request.kind,
86
97
  rules: added,
87
98
  settingsFile: request.settingsFile,
99
+ scope: request.scope,
88
100
  call: request.call,
89
101
  shown: request.shown,
90
102
  claudeSessionId: request.claudeSessionId,
@@ -92,6 +104,7 @@ async function grantRules(request, now = Date.now()) {
92
104
  addedAt: new Date(now).toISOString(),
93
105
  ...(request.kind === "once" ? { expiresAt: new Date(now + exports.ONCE_TTL_MS).toISOString() } : {}),
94
106
  retries: 0,
107
+ ...(request.unconfirmed ? { unconfirmed: { ...request.unconfirmed, since: new Date(now).toISOString() } } : {}),
95
108
  };
96
109
  try {
97
110
  writeGrants([...readGrants(), grant]);
@@ -140,10 +153,55 @@ async function consumeOnceGrants(claudeSessionId, call) {
140
153
  async function sweepExpiredGrants(now = Date.now()) {
141
154
  return revokeWhere((g) => g.kind === "once" && isExpired(g, now));
142
155
  }
143
- /** Is there any once-grant at all? The after-hook's fast exit, read without the lock. */
156
+ /** Is there any once-grant at all? Read without the lock. */
144
157
  function hasOnceGrants() {
145
158
  return readGrants().some((g) => g.kind === "once");
146
159
  }
160
+ /** Anything a sweep could act on — a once-grant, or one still unconfirmed? The after-hook's fast
161
+ * exit, read without the lock. */
162
+ function hasGrantsToSweep() {
163
+ return readGrants().some((g) => g.kind === "once" || g.unconfirmed !== undefined);
164
+ }
165
+ /** One grant by id, read without the lock. */
166
+ function findGrant(id) {
167
+ return readGrants().find((g) => g.id === id);
168
+ }
169
+ /** The call was found in the transcript: the grant stands. False: no such grant (any more). */
170
+ async function markConfirmed(id) {
171
+ return withGrantsLock(() => {
172
+ const grants = readGrants();
173
+ if (!grants.some((g) => g.id === id))
174
+ return false;
175
+ writeGrants(grants.map((g) => (g.id === id ? withoutUnconfirmed(g) : g)));
176
+ return true;
177
+ });
178
+ }
179
+ /** Is this grant still waiting for a confirmation that should have finished by `now`? */
180
+ function isStaleUnconfirmed(grant, now = Date.now()) {
181
+ if (!grant.unconfirmed)
182
+ return false;
183
+ const since = Date.parse(grant.unconfirmed.since);
184
+ // Unreadable: it can only be taken back early.
185
+ return !Number.isFinite(since) || since + exports.UNCONFIRMED_STALE_MS <= now;
186
+ }
187
+ /** Is any grant's confirmation overdue? Read without the lock (the after-hook's cheap check). */
188
+ function hasStaleUnconfirmed(now = Date.now()) {
189
+ return readGrants().some((g) => isStaleUnconfirmed(g, now));
190
+ }
191
+ /** Take back every grant whose confirmation never finished. Returns them, for the owner's notice. */
192
+ async function sweepStaleUnconfirmed(now = Date.now()) {
193
+ return withGrantsLock(() => {
194
+ let grants = readGrants();
195
+ const doomed = grants.filter((g) => isStaleUnconfirmed(g, now));
196
+ for (const grant of doomed)
197
+ grants = revokeLocked(grant, grants);
198
+ return doomed;
199
+ });
200
+ }
201
+ function withoutUnconfirmed(grant) {
202
+ const { unconfirmed: _done, ...rest } = grant;
203
+ return rest;
204
+ }
147
205
  async function revokeWhere(match) {
148
206
  return withGrantsLock(() => {
149
207
  let grants = readGrants();
@@ -0,0 +1,75 @@
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.deniedHookLogPath = deniedHookLogPath;
37
+ exports.appendHookLog = appendHookLog;
38
+ exports.logBoth = logBoth;
39
+ // `~/.baychat/denied-hook.log` — the denied-command card's own log.
40
+ //
41
+ // Claude Code ignores a PermissionDenied hook's stderr, so without a file a silent hook is
42
+ // undiagnosable: "no card came" could mean never called, not joined, unproven call, or a failed
43
+ // post. Shared by the hook and by the detached `baychat permissions confirm` it starts, so one
44
+ // file tells the whole story of a denial. One line per step, NEVER the tool input (it can hold
45
+ // secrets), kept small.
46
+ const fs = __importStar(require("fs"));
47
+ const path = __importStar(require("path"));
48
+ const config_1 = require("../config");
49
+ const HOOK_LOG_MAX_BYTES = 256 * 1024;
50
+ function deniedHookLogPath() {
51
+ return path.join((0, config_1.configDir)(), "denied-hook.log");
52
+ }
53
+ /** Append one line, rotating at HOOK_LOG_MAX_BYTES. NEVER THROWS. */
54
+ function appendHookLog(line) {
55
+ try {
56
+ const file = deniedHookLogPath();
57
+ try {
58
+ if (fs.statSync(file).size > HOOK_LOG_MAX_BYTES)
59
+ fs.renameSync(file, `${file}.1`);
60
+ }
61
+ catch {
62
+ // No log yet: nothing to rotate.
63
+ }
64
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
65
+ fs.appendFileSync(file, `${new Date().toISOString()} ${line}\n`, { mode: 0o600 });
66
+ }
67
+ catch (err) {
68
+ process.stderr.write(`baychat denied-hook: could not write its log (${err instanceof Error ? err.message : String(err)}).\n`);
69
+ }
70
+ }
71
+ /** stderr and the log file: what every denied-command process logs through. */
72
+ function logBoth(line) {
73
+ process.stderr.write(`${line}\n`);
74
+ appendHookLog(line);
75
+ }
@@ -0,0 +1,154 @@
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.proveLiveSession = proveLiveSession;
37
+ exports.readClaudeSessionFile = readClaudeSessionFile;
38
+ // The second proof a denial is real, for the moment the transcript cannot give the first: the
39
+ // event came from the live Claude Code that runs the session it names.
40
+ //
41
+ // WHY THIS EXISTS. Claude Code 2.1.299 writes the denied `tool_use` to the transcript only after
42
+ // the PermissionDenied hook returns (measured; transcript.ts), so the hook can never find it
43
+ // while it waits. What the hook CAN check while it waits:
44
+ //
45
+ // (a) the event's `transcript_path` is a file in Claude Code's own `projects/` folder (followed
46
+ // through symlinks) named `<session_id>.jsonl` — the session's own transcript, not one the
47
+ // event made up; and
48
+ // (b) one of the hook's own ancestors (≤ MAX_CLAUDE_HOPS from its parent) is a LIVE Claude Code
49
+ // whose `~/.claude/sessions/<pid>.json` (Claude Code writes it for every running session)
50
+ // says `sessionId` = the event's `session_id`, and that file belongs to the process that has
51
+ // the pid now, not to an earlier one that died and left its pid to be reused.
52
+ //
53
+ // WHAT IT DOES NOT PROVE. The agent's own Bash tool is a child of the same Claude Code, so an
54
+ // `echo '{…}' | baychat denied-hook` it runs passes (b) as well. That is why a call accepted this
55
+ // way is CONFIRMED afterwards against the transcript (confirm.ts) and taken back when it never
56
+ // shows up, and why the boundary remains the owner's tap on the exact command the card shows.
57
+ //
58
+ // PID REUSE. The file carries `startedAt` (ms) and, on Linux, `procStart` = field 22 of
59
+ // `/proc/<pid>/stat` (start time in clock ticks). When both that and the live value exist they
60
+ // must be EQUAL. Otherwise `startedAt` must lie within a few seconds of the start `ps` reports.
61
+ const fs = __importStar(require("fs"));
62
+ const path = __importStar(require("path"));
63
+ const approve_hook_1 = require("../approve-hook");
64
+ const claude_link_1 = require("../channel/claude-link");
65
+ const transcript_1 = require("./transcript");
66
+ /** `ps -o etime` has whole seconds, and the clock is read twice: this much early is the same start. */
67
+ const STARTED_EARLY_SLACK_MS = 3_000;
68
+ /** Claude Code writes its session file once it has started — 4.4 s after the kernel's start time
69
+ * on this VPS (2026-10-14); a busy disk makes it slower. Later than this is not that process's. */
70
+ const STARTED_LATE_SLACK_MS = 30_000;
71
+ /** A session id or agent id is one path segment of safe characters, never a path. */
72
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
73
+ /** Did this event come from the live Claude Code of the session it names? NEVER THROWS. */
74
+ function proveLiveSession(ref, deps = {}) {
75
+ const claudeDir = (0, transcript_1.claudeConfigDir)(deps.claudeDir);
76
+ const realpath = deps.realpath ?? ((f) => fs.realpathSync(f));
77
+ const parentOf = deps.parentOf ?? approve_hook_1.psParentOf;
78
+ const isAlive = deps.isAlive ?? claude_link_1.pidAlive;
79
+ const readSessionFile = deps.readSessionFile ?? ((pid) => readClaudeSessionFile(claudeDir, pid));
80
+ const procStart = deps.procStart ?? approve_hook_1.procStartTicks;
81
+ const ageMs = deps.ageMs ?? approve_hook_1.psAgeMs;
82
+ const now = deps.now ?? Date.now;
83
+ try {
84
+ // (a) The session's own transcript, in Claude Code's own folder.
85
+ if (!SAFE_ID.test(ref.sessionId))
86
+ return { ok: false, why: "the event's session id is not one Claude Code writes" };
87
+ if (ref.agentId !== undefined && !SAFE_ID.test(ref.agentId))
88
+ return { ok: false, why: "the event's agent id is not one Claude Code writes" };
89
+ const expected = `${ref.sessionId}.jsonl`;
90
+ if (!path.isAbsolute(ref.transcriptPath) || path.basename(ref.transcriptPath) !== expected) {
91
+ return { ok: false, why: `the event's transcript is not named ${expected}` };
92
+ }
93
+ const projects = realpath(path.join(claudeDir, "projects"));
94
+ let real;
95
+ try {
96
+ real = realpath(ref.transcriptPath);
97
+ }
98
+ catch {
99
+ // Not written yet (a session's very first turn): its folder must still be Claude Code's.
100
+ real = path.join(realpath(path.dirname(ref.transcriptPath)), expected);
101
+ }
102
+ if (!real.startsWith(projects + path.sep) || path.basename(real) !== expected) {
103
+ return { ok: false, why: "the event's transcript is not in Claude Code's own folder" };
104
+ }
105
+ // (b) The nearest Claude Code above this process runs that session.
106
+ let pid = deps.ppid ?? process.ppid;
107
+ for (let hop = 0; hop < approve_hook_1.MAX_CLAUDE_HOPS && pid !== undefined && pid > 1; hop++) {
108
+ const file = readSessionFile(pid);
109
+ if (file && isAlive(pid)) {
110
+ // The nearest one decides: a hook runs under exactly one Claude Code.
111
+ if (file.sessionId !== ref.sessionId) {
112
+ return { ok: false, why: `the Claude Code this hook runs under (pid ${pid}) is another session` };
113
+ }
114
+ if (file.pid !== undefined && file.pid !== pid) {
115
+ return { ok: false, why: `the session file of pid ${pid} names another pid` };
116
+ }
117
+ const started = startMatches(pid, file, procStart, ageMs, now);
118
+ return started === true ? { ok: true, pid } : { ok: false, why: started };
119
+ }
120
+ pid = parentOf(pid);
121
+ }
122
+ return { ok: false, why: `no live Claude Code of this session among the hook's ${approve_hook_1.MAX_CLAUDE_HOPS} nearest ancestors` };
123
+ }
124
+ catch (err) {
125
+ return { ok: false, why: `the session could not be checked (${err instanceof Error ? err.message : String(err)})` };
126
+ }
127
+ }
128
+ /** True when `file` was written by the process that has `pid` now; otherwise why not. */
129
+ function startMatches(pid, file, procStart, ageMs, now) {
130
+ const recorded = typeof file.procStart === "string" || typeof file.procStart === "number" ? String(file.procStart) : undefined;
131
+ const live = recorded === undefined ? undefined : procStart(pid);
132
+ if (recorded !== undefined && live !== undefined) {
133
+ return recorded === live ? true : `pid ${pid} is a newer process than its session file (start ${live}, file ${recorded})`;
134
+ }
135
+ const startedAt = typeof file.startedAt === "number" ? file.startedAt : Number.NaN;
136
+ const age = ageMs(pid);
137
+ if (!Number.isFinite(startedAt) || age === undefined)
138
+ return `when pid ${pid} started cannot be told`;
139
+ const processStart = now() - age;
140
+ if (startedAt < processStart - STARTED_EARLY_SLACK_MS || startedAt > processStart + STARTED_LATE_SLACK_MS) {
141
+ return `pid ${pid}'s session file was written by another process (started ${new Date(startedAt).toISOString()}, the process ${new Date(processStart).toISOString()})`;
142
+ }
143
+ return true;
144
+ }
145
+ /** `<claudeDir>/sessions/<pid>.json`, or undefined when absent or not a JSON object. */
146
+ function readClaudeSessionFile(claudeDir, pid) {
147
+ try {
148
+ const parsed = JSON.parse(fs.readFileSync(path.join(claudeDir, "sessions", `${pid}.json`), "utf8"));
149
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
150
+ }
151
+ catch {
152
+ return undefined;
153
+ }
154
+ }
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NOTICE_OPTIONS = void 0;
4
+ exports.postNotice = postNotice;
5
+ exports.unconfirmedNotice = unconfirmedNotice;
6
+ // A card that only informs: posted to the owner's private chat with the session, never waited on.
7
+ // Shared by the hook and by the processes that take a grant back after it (confirm.ts).
8
+ const approval_wait_1 = require("../approval-wait");
9
+ /** The single button of a card that only informs: nothing can be allowed from it. */
10
+ exports.NOTICE_OPTIONS = ["OK"];
11
+ /** Post `question` as an info card. NEVER THROWS (askOwnerAndWait does not); its deadline is now. */
12
+ async function postNotice(device, session, question, deps, label) {
13
+ const outcome = await (0, approval_wait_1.askOwnerAndWait)(device, { session, question, options: exports.NOTICE_OPTIONS }, deps.now(), deps, label);
14
+ if (outcome.kind === "failed")
15
+ deps.log(`${label}: the notice could not be posted (${outcome.reason}).`);
16
+ }
17
+ /**
18
+ * The owner's notice when BayChat took back a grant it could not confirm. `used`: a once-grant
19
+ * whose rule the retried call had already used (and the after-hook removed) by then.
20
+ */
21
+ function unconfirmedNotice(grant, host, why, used) {
22
+ const who = grant.session ? `session "${grant.session}"` : "a Claude Code session";
23
+ return (`BayChat removed the rule you allowed for ${who} on ${host}:\n\n${grant.shown}\n\n` +
24
+ `It could not confirm that the block came from that session — Claude Code's auto mode never recorded ` +
25
+ `this call in the session's transcript (${why}).` +
26
+ (used ? " The rule had already been used once." : "") +
27
+ "\n\nIf you did not expect this card, check what is running on that computer. If you still want the call " +
28
+ "allowed, approve it at the computer (/permissions → Recently denied).");
29
+ }
@@ -49,7 +49,7 @@ function planDeniedCall(toolName, toolInput) {
49
49
  shown: `${toolName} ${clipJson(input)}`,
50
50
  onceRules: [toolName],
51
51
  alwaysAllowed: false,
52
- limitation: "A rule for an MCP tool allows every use of that tool, so BayChat only allows it once.",
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
53
  call: { tool: toolName, subject: toolName },
54
54
  };
55
55
  }
@@ -88,16 +88,19 @@ function planBash(command) {
88
88
  shown,
89
89
  onceRules: [...new Set(parts.map((p) => `Bash(${p})`))],
90
90
  alwaysAllowed: false,
91
- limitation: `It chains ${parts.length} commands, so BayChat only allows it once.`,
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".`,
92
95
  call,
93
96
  };
94
97
  }
95
98
  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."
99
+ ? "\"Always\" isn't offered: it carries what looks like a secret, and a saved rule would keep it in a settings file."
97
100
  : command.includes("$")
98
- ? "It expands a variable, so the same text could run something different later; BayChat only allows it once."
101
+ ? "\"Always\" isn't offered: it expands a variable, so the same text could run something different later."
99
102
  : /[<>]/.test(command)
100
- ? "It redirects to or from a file, so BayChat only allows it once."
103
+ ? "\"Always\" isn't offered: it redirects to or from a file."
101
104
  : "";
102
105
  return { shown, onceRules: [`Bash(${command})`], alwaysAllowed: refusal === "", limitation: refusal, call };
103
106
  }
@@ -116,7 +119,7 @@ function planWebFetch(url) {
116
119
  shown,
117
120
  onceRules: [`WebFetch(domain:${host})`],
118
121
  alwaysAllowed: false,
119
- limitation: `A WebFetch rule covers the whole of ${host}, so BayChat only allows it once.`,
122
+ limitation: `"Always" isn't offered: a WebFetch rule covers the whole of ${host}, so BayChat only allows it once.`,
120
123
  call: { tool: "WebFetch", subject: host },
121
124
  };
122
125
  }
@@ -35,6 +35,8 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.addAllowRules = addAllowRules;
37
37
  exports.removeAllowRules = removeAllowRules;
38
+ exports.chooseSettingsFile = chooseSettingsFile;
39
+ exports.settingsFileWritable = settingsFileWritable;
38
40
  exports.localSettingsFile = localSettingsFile;
39
41
  // Adding and removing exact `permissions.allow` entries in ONE Claude Code settings file — and
40
42
  // touching nothing else in it.
@@ -118,6 +120,65 @@ function writeAllow(edit, allow, io) {
118
120
  // A new file is private: a once-rule can carry a command's own arguments.
119
121
  io.write(edit.target, `${JSON.stringify(next, null, indent)}\n`, io.mode(edit.target) ?? 0o600);
120
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
+ }
121
182
  /**
122
183
  * The `.claude/settings.local.json` Claude Code reads for a session started in `projectDir`.
123
184
  *
@@ -33,34 +33,59 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.TRANSCRIPT_WAIT_MS = void 0;
36
+ exports.CONFIRM_WAIT_MS = exports.TRANSCRIPT_WAIT_MS = void 0;
37
+ exports.claudeConfigDir = claudeConfigDir;
38
+ exports.transcriptFileOf = transcriptFileOf;
37
39
  exports.verifyDeniedCall = verifyDeniedCall;
38
40
  exports.findToolUse = findToolUse;
39
41
  // Proof that a denial really happened: the denied call must be in the session's own transcript.
40
42
  //
41
43
  // WHY. `baychat denied-hook` reads its event from stdin, and stdin is something anybody can
42
44
  // 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.
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.
47
50
  //
48
51
  // It is defence in depth, not the boundary: the owner's answer is. A card the agent somehow did
49
52
  // cause still writes nothing unless the owner taps a yes.
50
53
  //
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.
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
+ //
53
64
  // For a subagent's call the hook still gets the MAIN transcript (with `agent_id`); its calls are
54
65
  // in `<session>/subagents/agent-<id>.jsonl` beside it.
55
66
  const fs = __importStar(require("fs"));
56
67
  const os = __importStar(require("os"));
57
68
  const path = __importStar(require("path"));
58
69
  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;
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;
61
76
  const TRANSCRIPT_POLL_MS = 250;
62
77
  /** Only the end of the file is read: the denied call is the newest thing in it. */
63
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
+ }
64
89
  /** Did the model really make this call in this session? NEVER THROWS. */
65
90
  async function verifyDeniedCall(ref, deps = {}) {
66
91
  const now = deps.now ?? Date.now;
@@ -68,17 +93,15 @@ async function verifyDeniedCall(ref, deps = {}) {
68
93
  const realpath = deps.realpath ?? ((f) => fs.realpathSync(f));
69
94
  const readTail = deps.readTail ?? readFileTail;
70
95
  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);
96
+ const projects = realpath(path.join(claudeConfigDir(deps.claudeDir), "projects"));
97
+ const wanted = ref.call ?? (0, permission_rule_1.callKeyOf)(ref.toolName, ref.toolInput);
73
98
  if (!ref.toolUseId || !wanted)
74
99
  return { ok: false, why: "the event names no call that can be checked" };
75
100
  if (!path.isAbsolute(ref.transcriptPath) || !ref.transcriptPath.endsWith(".jsonl")) {
76
101
  return { ok: false, why: "the event carries no transcript" };
77
102
  }
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;
103
+ const file = transcriptFileOf(ref.transcriptPath, ref.agentId);
104
+ const giveUpAt = now() + (deps.waitMs ?? exports.TRANSCRIPT_WAIT_MS);
82
105
  for (;;) {
83
106
  let real;
84
107
  try {
@@ -99,7 +122,7 @@ async function verifyDeniedCall(ref, deps = {}) {
99
122
  : { ok: false, why: "the transcript's call is not the one the event names" };
100
123
  }
101
124
  if (now() >= giveUpAt)
102
- return { ok: false, why: "the call is not in the session's transcript" };
125
+ return { ok: false, why: "the call is not in the session's transcript", absent: true };
103
126
  await sleep(TRANSCRIPT_POLL_MS);
104
127
  }
105
128
  }