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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +43 -10
- package/dist/approval-wait.js +143 -0
- package/dist/approve-hook.js +68 -133
- package/dist/auto-update/describe.js +66 -0
- package/dist/auto-update/notice.js +40 -0
- package/dist/auto-update/policy.js +110 -0
- package/dist/auto-update/relay-glue.js +143 -0
- package/dist/auto-update/store.js +140 -0
- package/dist/auto-update/updater.js +324 -0
- package/dist/channel/permission.js +6 -0
- package/dist/cli-help.js +30 -3
- package/dist/denied/after-hook.js +46 -0
- package/dist/denied/confirm.js +131 -0
- package/dist/denied/grants.js +292 -0
- package/dist/denied/hook-log.js +75 -0
- package/dist/denied/live-session.js +154 -0
- package/dist/denied/notice.js +29 -0
- package/dist/denied/opt-in.js +68 -0
- package/dist/denied/permission-rule.js +186 -0
- package/dist/denied/settings-rules.js +221 -0
- package/dist/denied/transcript.js +173 -0
- package/dist/denied-hook.js +431 -0
- package/dist/doctor-command.js +7 -1
- package/dist/doctor.js +28 -0
- package/dist/help-topics.js +12 -4
- package/dist/hook-stdin.js +35 -0
- package/dist/index.js +13 -1
- package/dist/main.js +20 -2
- package/dist/permissions-command.js +104 -0
- package/dist/protocol-content.js +1 -1
- package/dist/refresh-clients.js +36 -0
- package/dist/relay/acp/runner.js +4 -0
- package/dist/relay/commands.js +19 -3
- package/dist/relay/daemon.js +37 -1
- package/dist/relay/launcher/approval-settings.js +56 -2
- package/dist/relay/launcher/loop.js +4 -1
- package/dist/relay/self-restart.js +149 -0
- package/dist/relay/terminal-pane.js +74 -2
- package/dist/runtimes.js +2 -2
- package/dist/self-upgrade.js +92 -27
- package/dist/start-command.js +15 -1
- package/dist/update-command.js +51 -31
- package/dist/waiting-cards.js +107 -0
- package/package.json +4 -1
|
@@ -0,0 +1,292 @@
|
|
|
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.UNCONFIRMED_STALE_MS = exports.ONCE_TTL_MS = void 0;
|
|
37
|
+
exports.grantRules = grantRules;
|
|
38
|
+
exports.listGrants = listGrants;
|
|
39
|
+
exports.coveringGrant = coveringGrant;
|
|
40
|
+
exports.noteRetry = noteRetry;
|
|
41
|
+
exports.revokeGrant = revokeGrant;
|
|
42
|
+
exports.consumeOnceGrants = consumeOnceGrants;
|
|
43
|
+
exports.sweepExpiredGrants = sweepExpiredGrants;
|
|
44
|
+
exports.hasOnceGrants = hasOnceGrants;
|
|
45
|
+
exports.hasGrantsToSweep = hasGrantsToSweep;
|
|
46
|
+
exports.findGrant = findGrant;
|
|
47
|
+
exports.markConfirmed = markConfirmed;
|
|
48
|
+
exports.isStaleUnconfirmed = isStaleUnconfirmed;
|
|
49
|
+
exports.hasStaleUnconfirmed = hasStaleUnconfirmed;
|
|
50
|
+
exports.sweepStaleUnconfirmed = sweepStaleUnconfirmed;
|
|
51
|
+
// The permission rules BayChat itself wrote on this computer, and the one owner of every change to
|
|
52
|
+
// them (`~/.baychat/permission-grants.json`, 0600).
|
|
53
|
+
//
|
|
54
|
+
// A GRANT is what one "Allow once" or "Always allow this exact command" answer did: the exact rules
|
|
55
|
+
// it added to which settings file, and the call they were for. Recording it is what lets BayChat
|
|
56
|
+
// take a once-rule back out after the retried call (or its TTL), list what it keeps
|
|
57
|
+
// (`baychat permissions list`) and remove one again (`baychat permissions remove`) — without ever
|
|
58
|
+
// touching a rule the person wrote themselves: a rule that was already in the file is not added,
|
|
59
|
+
// so it is not recorded, so it is never removed.
|
|
60
|
+
//
|
|
61
|
+
// ONE LOCK FOR ALL OF IT. The hook, the after-hook, the TTL sweeper and the command can run at the
|
|
62
|
+
// same time (two denials in one turn, a sweep while a retry lands), and each is a read-modify-write
|
|
63
|
+
// of two files. Every operation below runs under `permission-grants.lock` (an O_EXCL file, broken
|
|
64
|
+
// when older than STALE_LOCK_MS), so they queue instead of losing each other's writes.
|
|
65
|
+
const crypto = __importStar(require("crypto"));
|
|
66
|
+
const fs = __importStar(require("fs"));
|
|
67
|
+
const path = __importStar(require("path"));
|
|
68
|
+
const config_1 = require("../config");
|
|
69
|
+
const permission_rule_1 = require("./permission-rule");
|
|
70
|
+
const transcript_1 = require("./transcript");
|
|
71
|
+
const settings_rules_1 = require("./settings-rules");
|
|
72
|
+
/** How long a once-rule may outlive its card when the retried call never reports back. The retry
|
|
73
|
+
* starts within seconds; this only bounds a rule left behind by a session that went away. */
|
|
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;
|
|
79
|
+
const LOCK_WAIT_MS = 5_000;
|
|
80
|
+
const LOCK_POLL_MS = 50;
|
|
81
|
+
const STALE_LOCK_MS = 30_000;
|
|
82
|
+
const grantsFile = () => path.join((0, config_1.configDir)(), "permission-grants.json");
|
|
83
|
+
const lockFile = () => path.join((0, config_1.configDir)(), "permission-grants.lock");
|
|
84
|
+
/**
|
|
85
|
+
* Write the rules into the settings file and record the grant. All or nothing: when the record
|
|
86
|
+
* cannot be written the rules are taken back out, and when the rules cannot be written nothing is
|
|
87
|
+
* recorded.
|
|
88
|
+
*
|
|
89
|
+
* @throws with nothing changed (as far as the filesystem allows) when either write fails.
|
|
90
|
+
*/
|
|
91
|
+
async function grantRules(request, now = Date.now()) {
|
|
92
|
+
return withGrantsLock(() => {
|
|
93
|
+
const added = (0, settings_rules_1.addAllowRules)(request.settingsFile, request.rules);
|
|
94
|
+
const grant = {
|
|
95
|
+
id: crypto.randomBytes(4).toString("hex"),
|
|
96
|
+
kind: request.kind,
|
|
97
|
+
rules: added,
|
|
98
|
+
settingsFile: request.settingsFile,
|
|
99
|
+
scope: request.scope,
|
|
100
|
+
call: request.call,
|
|
101
|
+
shown: request.shown,
|
|
102
|
+
claudeSessionId: request.claudeSessionId,
|
|
103
|
+
...(request.session ? { session: request.session } : {}),
|
|
104
|
+
addedAt: new Date(now).toISOString(),
|
|
105
|
+
...(request.kind === "once" ? { expiresAt: new Date(now + exports.ONCE_TTL_MS).toISOString() } : {}),
|
|
106
|
+
retries: 0,
|
|
107
|
+
...(request.unconfirmed ? { unconfirmed: { ...request.unconfirmed, since: new Date(now).toISOString() } } : {}),
|
|
108
|
+
};
|
|
109
|
+
try {
|
|
110
|
+
writeGrants([...readGrants(), grant]);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
(0, settings_rules_1.removeAllowRules)(request.settingsFile, added);
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
return grant;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/** Every recorded grant, once-grants included. */
|
|
120
|
+
function listGrants() {
|
|
121
|
+
return readGrants();
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The live grant that already covers this call, if any: an always-grant from any session, or a
|
|
125
|
+
* once-grant of this Claude Code session that has not expired.
|
|
126
|
+
*/
|
|
127
|
+
function coveringGrant(claudeSessionId, call, now = Date.now()) {
|
|
128
|
+
return readGrants().find((g) => (0, permission_rule_1.sameCall)(g.call, call) &&
|
|
129
|
+
(g.kind === "always" || (g.claudeSessionId === claudeSessionId && !isExpired(g, now))));
|
|
130
|
+
}
|
|
131
|
+
/** Count one silent retry against a grant. */
|
|
132
|
+
async function noteRetry(id) {
|
|
133
|
+
await withGrantsLock(() => {
|
|
134
|
+
writeGrants(readGrants().map((g) => (g.id === id ? { ...g, retries: g.retries + 1 } : g)));
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/** Take a grant back: its rules out of the settings file, then its record. False: no such grant. */
|
|
138
|
+
async function revokeGrant(id) {
|
|
139
|
+
return withGrantsLock(() => {
|
|
140
|
+
const grants = readGrants();
|
|
141
|
+
const grant = grants.find((g) => g.id === id);
|
|
142
|
+
if (!grant)
|
|
143
|
+
return false;
|
|
144
|
+
revokeLocked(grant, grants);
|
|
145
|
+
return true;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
/** After a call finished: take back the once-grants of this session that were for it. */
|
|
149
|
+
async function consumeOnceGrants(claudeSessionId, call) {
|
|
150
|
+
return revokeWhere((g) => g.kind === "once" && g.claudeSessionId === claudeSessionId && (0, permission_rule_1.sameCall)(g.call, call));
|
|
151
|
+
}
|
|
152
|
+
/** Take back every once-grant whose TTL has passed. */
|
|
153
|
+
async function sweepExpiredGrants(now = Date.now()) {
|
|
154
|
+
return revokeWhere((g) => g.kind === "once" && isExpired(g, now));
|
|
155
|
+
}
|
|
156
|
+
/** Is there any once-grant at all? Read without the lock. */
|
|
157
|
+
function hasOnceGrants() {
|
|
158
|
+
return readGrants().some((g) => g.kind === "once");
|
|
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
|
+
}
|
|
205
|
+
async function revokeWhere(match) {
|
|
206
|
+
return withGrantsLock(() => {
|
|
207
|
+
let grants = readGrants();
|
|
208
|
+
const doomed = grants.filter(match);
|
|
209
|
+
for (const grant of doomed)
|
|
210
|
+
grants = revokeLocked(grant, grants);
|
|
211
|
+
return doomed.length;
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
/** Remove `grant`'s rules — except any another remaining grant also holds — then its record.
|
|
215
|
+
* Returns the remaining grants. Caller holds the lock. */
|
|
216
|
+
function revokeLocked(grant, grants) {
|
|
217
|
+
const rest = grants.filter((g) => g.id !== grant.id);
|
|
218
|
+
const stillHeld = new Set(rest.filter((g) => g.settingsFile === grant.settingsFile).flatMap((g) => g.rules));
|
|
219
|
+
(0, settings_rules_1.removeAllowRules)(grant.settingsFile, grant.rules.filter((r) => !stillHeld.has(r)));
|
|
220
|
+
writeGrants(rest);
|
|
221
|
+
return rest;
|
|
222
|
+
}
|
|
223
|
+
function isExpired(grant, now) {
|
|
224
|
+
const at = grant.expiresAt ? Date.parse(grant.expiresAt) : Number.NaN;
|
|
225
|
+
// A once-grant with no readable expiry is treated as expired: it can only be taken back early.
|
|
226
|
+
return grant.kind === "once" && (!Number.isFinite(at) || at <= now);
|
|
227
|
+
}
|
|
228
|
+
// ─── The file ────────────────────────────────────────────────────────────────
|
|
229
|
+
function readGrants() {
|
|
230
|
+
let text;
|
|
231
|
+
try {
|
|
232
|
+
text = fs.readFileSync(grantsFile(), "utf8");
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
if (err.code === "ENOENT")
|
|
236
|
+
return [];
|
|
237
|
+
throw err;
|
|
238
|
+
}
|
|
239
|
+
const parsed = JSON.parse(text);
|
|
240
|
+
const grants = parsed?.grants;
|
|
241
|
+
if (!Array.isArray(grants))
|
|
242
|
+
throw new Error(`${grantsFile()} is not a BayChat grants file.`);
|
|
243
|
+
return grants;
|
|
244
|
+
}
|
|
245
|
+
function writeGrants(grants) {
|
|
246
|
+
const file = grantsFile();
|
|
247
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
248
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
249
|
+
fs.writeFileSync(tmp, `${JSON.stringify({ version: 1, grants }, null, 2)}\n`, { mode: 0o600 });
|
|
250
|
+
fs.renameSync(tmp, file);
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Run `fn` holding the grants lock. The lock is an exclusive-create file; one older than
|
|
254
|
+
* STALE_LOCK_MS belonged to a process that died holding it and is broken. Gives up (throws) after
|
|
255
|
+
* LOCK_WAIT_MS rather than waiting forever — every caller fails closed on a throw.
|
|
256
|
+
*/
|
|
257
|
+
async function withGrantsLock(fn) {
|
|
258
|
+
const file = lockFile();
|
|
259
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
260
|
+
const giveUpAt = Date.now() + LOCK_WAIT_MS;
|
|
261
|
+
for (;;) {
|
|
262
|
+
try {
|
|
263
|
+
fs.closeSync(fs.openSync(file, "wx", 0o600));
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
if (err.code !== "EEXIST")
|
|
268
|
+
throw err;
|
|
269
|
+
if (lockAge(file) > STALE_LOCK_MS) {
|
|
270
|
+
fs.rmSync(file, { force: true });
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (Date.now() >= giveUpAt)
|
|
274
|
+
throw new Error(`Another BayChat process holds ${file}; try again.`);
|
|
275
|
+
await new Promise((r) => setTimeout(r, LOCK_POLL_MS));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
return fn();
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
fs.rmSync(file, { force: true });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function lockAge(file) {
|
|
286
|
+
try {
|
|
287
|
+
return Date.now() - fs.statSync(file).mtimeMs;
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return 0;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.deniedCardEnabled = deniedCardEnabled;
|
|
37
|
+
exports.enableDeniedCard = enableDeniedCard;
|
|
38
|
+
exports.disableDeniedCard = disableDeniedCard;
|
|
39
|
+
// Whether this computer's owner turned the denied-command card on (`baychat permissions on`).
|
|
40
|
+
//
|
|
41
|
+
// OFF UNTIL SOMEBODY ASKS FOR IT, like approve-hook was: the card lets a phone add a permission
|
|
42
|
+
// rule on this computer, so it exists only after a deliberate act. The switch is a file in
|
|
43
|
+
// `~/.baychat`, read when BayChat's launcher writes a session's `--settings` file
|
|
44
|
+
// (relay/launcher/approval-settings.ts), so it takes effect for sessions started after it changes.
|
|
45
|
+
const fs = __importStar(require("fs"));
|
|
46
|
+
const path = __importStar(require("path"));
|
|
47
|
+
const config_1 = require("../config");
|
|
48
|
+
const optInFile = () => path.join((0, config_1.configDir)(), "denied-card.json");
|
|
49
|
+
/** True only when the switch file says so. Any doubt — missing, unreadable, malformed — is off. */
|
|
50
|
+
function deniedCardEnabled() {
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(fs.readFileSync(optInFile(), "utf8"));
|
|
53
|
+
return parsed?.enabled === true;
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
if (err.code !== "ENOENT") {
|
|
57
|
+
process.stderr.write(`baychat: ${optInFile()} could not be read, so the denied-command card is off.\n`);
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function enableDeniedCard(now = new Date()) {
|
|
63
|
+
fs.mkdirSync((0, config_1.configDir)(), { recursive: true, mode: 0o700 });
|
|
64
|
+
fs.writeFileSync(optInFile(), `${JSON.stringify({ enabled: true, since: now.toISOString() }, null, 2)}\n`, { mode: 0o600 });
|
|
65
|
+
}
|
|
66
|
+
function disableDeniedCard() {
|
|
67
|
+
fs.rmSync(optInFile(), { force: true });
|
|
68
|
+
}
|